mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-02-14 17:19:35 +01:00
Compare commits
10 commits
d454f91912
...
d06ac6210f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06ac6210f | ||
|
|
a7691ccf43 | ||
|
|
16ccc978f9 | ||
|
|
6ba3ccf415 | ||
|
|
8d7cc06b5e | ||
|
|
8151587036 | ||
|
|
0c8db0b597 | ||
|
|
d2d187d4d5 | ||
|
|
197d9b923e | ||
|
|
345935f8b9 |
37 changed files with 1901 additions and 540 deletions
|
|
@ -34,6 +34,7 @@ SMTP_SECURE=true # Use SSL (true
|
|||
# --- HTTP / WEB CONFIGURATION ---
|
||||
HTTP_PORT=3000 # Port
|
||||
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_DISPLAY_SORT=2 # Domain display sorting:
|
||||
# 0 = no change,
|
||||
|
|
@ -42,12 +43,11 @@ HTTP_DISPLAY_SORT=2 # Domain display
|
|||
# 3 = shuffle 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_SHOW_INFO_SECTION=true # Show info section on homepage (true/false)
|
||||
|
||||
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
||||
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_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails 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)
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
.env
|
||||
.env.backup
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -26,16 +26,16 @@ All data is being removed 48hrs after they have reached the mail server.
|
|||
|
||||
## Features
|
||||
|
||||
- Create a custom inbox with select name and domain, or get a fully randomized one
|
||||
- Receive emails with a clean preview in your inbox, with optional browser notifications
|
||||
- Read emails, with support for HTML, CSS & JS just like you are used to from regular email providers
|
||||
- Automatic detection and display of cryptographic keys and signatures
|
||||
- Delete your emails ahead of time by pressing the delete button
|
||||
- View the raw email, showing all the headers etc.
|
||||
- Download Attachments with one click
|
||||
- <u>Optional</u> User Account System with email forwarding and inbox locking
|
||||
- <u>Optional</u> Statistics System, tracking public data for as long as your mails stay
|
||||
- and more...
|
||||
- **Custom or Random Inboxes** - Choose your own address or get one instantly
|
||||
- **Real-time Updates** - Live email reception with Socket.IO and browser notifications
|
||||
- **Full Email Rendering** - HTML, CSS, JavaScript support with attachment downloads
|
||||
- **Raw Email View** - Inspect headers, MIME structure, and source
|
||||
- **Cryptographic Key Detection** - Automatic PGP key and signature display
|
||||
- **QR Code Generation** - Easy mobile access to your inbox
|
||||
- **Dark/Light Theme** - Fully responsive design with theme toggle
|
||||
- **Optional User Accounts** - Email forwarding and inbox locking (requires SMTP)
|
||||
- **Optional Statistics** - Real-time metrics, historical analysis, and predictive charts
|
||||
- **Highly Configurable** - Customize purge time, domains, branding, features, and limits via `.env`
|
||||
|
||||
<br>
|
||||
|
||||
|
|
|
|||
170
app.js
170
app.js
|
|
@ -2,6 +2,22 @@
|
|||
|
||||
/* 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 debug = require('debug')('48hr-email:app')
|
||||
const Helper = require('./application/helper')
|
||||
|
|
@ -9,21 +25,30 @@ const helper = new(Helper)
|
|||
const { app, io, server } = require('./infrastructure/web/web')
|
||||
const ClientNotification = require('./infrastructure/web/client-notification')
|
||||
const ImapService = require('./application/imap-service')
|
||||
const MockMailService = require('./application/mocks/mock-mail-service')
|
||||
const MailProcessingService = require('./application/mail-processing-service')
|
||||
const SmtpService = require('./application/smtp-service')
|
||||
const AuthService = require('./application/auth-service')
|
||||
const MockAuthService = require('./application/mocks/mock-auth-service')
|
||||
const MailRepository = require('./domain/mail-repository')
|
||||
const InboxLock = require('./domain/inbox-lock')
|
||||
const MockInboxLock = require('./application/mocks/mock-inbox-lock')
|
||||
const VerificationStore = require('./domain/verification-store')
|
||||
const UserRepository = require('./domain/user-repository')
|
||||
const MockUserRepository = require('./application/mocks/mock-user-repository')
|
||||
const StatisticsStore = require('./domain/statistics-store')
|
||||
|
||||
const clientNotification = new ClientNotification()
|
||||
debug('Client notification service initialized')
|
||||
clientNotification.use(io)
|
||||
|
||||
const smtpService = new SmtpService(config)
|
||||
debug('SMTP service initialized')
|
||||
// Initialize SMTP service only if not in UX debug mode
|
||||
const smtpService = config.uxDebugMode ? null : new SmtpService(config)
|
||||
if (smtpService) {
|
||||
debug('SMTP service initialized')
|
||||
} else {
|
||||
debug('SMTP service disabled (UX debug mode)')
|
||||
}
|
||||
app.set('smtpService', smtpService)
|
||||
|
||||
const verificationStore = new VerificationStore()
|
||||
|
|
@ -37,7 +62,7 @@ app.set('config', config)
|
|||
let inboxLock = null
|
||||
let statisticsStore = null
|
||||
|
||||
if (config.user.authEnabled) {
|
||||
if (config.user.authEnabled && !config.uxDebugMode) {
|
||||
// Migrate legacy database files for backwards compatibility
|
||||
Helper.migrateDatabase(config.user.databasePath)
|
||||
|
||||
|
|
@ -74,22 +99,51 @@ if (config.user.authEnabled) {
|
|||
})
|
||||
}
|
||||
}, config.imap.refreshIntervalSeconds * 1000)
|
||||
|
||||
console.log('User authentication system enabled')
|
||||
} else {
|
||||
// No auth enabled - initialize statistics store without persistence
|
||||
// No auth enabled OR UX debug mode - initialize statistics store without persistence
|
||||
statisticsStore = new StatisticsStore()
|
||||
debug('Statistics store initialized (in-memory only, no database)')
|
||||
app.set('statisticsStore', statisticsStore)
|
||||
if (config.uxDebugMode) {
|
||||
debug('Statistics store initialized (UX debug mode - clean slate)')
|
||||
|
||||
// In UX debug mode, create mock auth system
|
||||
const mockUserRepository = new MockUserRepository(config)
|
||||
debug('Mock user repository initialized')
|
||||
app.set('userRepository', mockUserRepository)
|
||||
|
||||
const mockAuthService = new MockAuthService()
|
||||
debug('Mock auth service initialized')
|
||||
app.set('authService', mockAuthService)
|
||||
|
||||
inboxLock = new MockInboxLock(mockUserRepository)
|
||||
app.set('inboxLock', inboxLock)
|
||||
debug('Mock inbox lock service initialized')
|
||||
|
||||
debug('Mock authentication system enabled for UX debug mode')
|
||||
} else {
|
||||
debug('Statistics store initialized (in-memory only, no database)')
|
||||
app.set('userRepository', null)
|
||||
app.set('authService', null)
|
||||
app.set('inboxLock', null)
|
||||
debug('User authentication system disabled')
|
||||
}
|
||||
app.set('statisticsStore', statisticsStore)
|
||||
|
||||
if (!config.uxDebugMode) {
|
||||
debug('User authentication system disabled')
|
||||
}
|
||||
}
|
||||
|
||||
const imapService = new ImapService(config, inboxLock)
|
||||
debug('IMAP service initialized')
|
||||
// Initialize IMAP or Mock service based on debug mode
|
||||
const imapService = config.uxDebugMode ?
|
||||
new MockMailService(config) :
|
||||
new ImapService(config, inboxLock)
|
||||
|
||||
if (config.uxDebugMode) {
|
||||
debug('Mock Mail Service initialized (UX Debug Mode)')
|
||||
} else {
|
||||
debug('IMAP service initialized')
|
||||
}
|
||||
app.set('imapService', imapService)
|
||||
|
||||
const mailProcessingService = new MailProcessingService(
|
||||
new MailRepository(),
|
||||
|
|
@ -104,13 +158,29 @@ debug('Mail processing service initialized')
|
|||
|
||||
// Initialize statistics with current count
|
||||
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, async() => {
|
||||
// In UX debug mode, populate mock emails first
|
||||
if (config.uxDebugMode) {
|
||||
// Load mock emails into repository
|
||||
const mockEmails = imapService.getMockEmails()
|
||||
mockEmails.forEach(({ mail }) => {
|
||||
mailProcessingService.onNewMail(mail)
|
||||
})
|
||||
debug(`UX Debug Mode: Loaded ${mockEmails.length} mock emails`)
|
||||
}
|
||||
|
||||
// Then initialize statistics with the correct count
|
||||
const count = mailProcessingService.getCount()
|
||||
statisticsStore.initialize(count)
|
||||
|
||||
if (config.uxDebugMode) {
|
||||
statisticsStore.updateLargestUid(2) // 2 mock emails
|
||||
debug(`UX Debug Mode: Statistics initialized with ${count} emails, largest UID: 2`)
|
||||
} else {
|
||||
// Get and set the largest UID for all-time total
|
||||
const largestUid = await helper.getLargestUid(imapService)
|
||||
statisticsStore.updateLargestUid(largestUid)
|
||||
debug(`Statistics initialized with ${count} emails, largest UID: ${largestUid}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up timer sync broadcasting after IMAP is ready
|
||||
|
|
@ -118,6 +188,69 @@ imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
|
|||
clientNotification.startTimerSync(imapService)
|
||||
})
|
||||
|
||||
// Display startup banner when everything is ready
|
||||
let imapReady = false
|
||||
let serverReady = false
|
||||
|
||||
function displayStartupBanner() {
|
||||
if (!imapReady || !serverReady) return
|
||||
|
||||
const mailCount = mailProcessingService.getCount()
|
||||
const domains = config.email.domains.join(', ')
|
||||
const purgeTime = `${config.email.purgeTime.time} ${config.email.purgeTime.unit}`
|
||||
const refreshInterval = config.uxDebugMode ? 'N/A' : `${config.imap.refreshIntervalSeconds}s`
|
||||
|
||||
// Determine mode based on environment
|
||||
let mode = 'PRODUCTION'
|
||||
if (config.uxDebugMode) {
|
||||
mode = 'UX DEBUG'
|
||||
} else if (process.env.DEBUG) {
|
||||
mode = 'DEBUG'
|
||||
}
|
||||
|
||||
console.log('\n' + '═'.repeat(70))
|
||||
console.log(` 48hr.email - ${mode} MODE`)
|
||||
console.log('═'.repeat(70))
|
||||
console.log(` Server: http://localhost:${config.http.port}`)
|
||||
console.log(` Domains: ${domains}`)
|
||||
console.log(` Emails loaded: ${mailCount}`)
|
||||
console.log(` Purge after: ${purgeTime}`)
|
||||
console.log(` IMAP refresh: ${refreshInterval}`)
|
||||
|
||||
if (!config.uxDebugMode && config.email.examples.account && config.email.examples.uids) {
|
||||
console.log(` Example inbox: ${config.email.examples.account}`)
|
||||
console.log(` Example UIDs: ${config.email.examples.uids.join(', ')}`)
|
||||
}
|
||||
|
||||
if (config.uxDebugMode) {
|
||||
console.log(` Authentication: Mock (any username/password works)`)
|
||||
const mockUserRepo = app.get('userRepository')
|
||||
if (mockUserRepo) {
|
||||
console.log(` Demo forward: ${mockUserRepo.mockForwardEmail}`)
|
||||
console.log(` Demo locked: ${mockUserRepo.mockLockedInbox}`)
|
||||
}
|
||||
} else if (config.user.authEnabled) {
|
||||
console.log(` Authentication: Enabled`)
|
||||
}
|
||||
|
||||
if (config.http.features.statistics) {
|
||||
console.log(` Statistics: Enabled`)
|
||||
}
|
||||
|
||||
console.log('═'.repeat(70))
|
||||
console.log(` Ready! Press Ctrl+C to stop\n`)
|
||||
}
|
||||
|
||||
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
|
||||
imapReady = true
|
||||
displayStartupBanner()
|
||||
})
|
||||
|
||||
server.on('ready', () => {
|
||||
serverReady = true
|
||||
displayStartupBanner()
|
||||
})
|
||||
|
||||
// Track IMAP initialization state
|
||||
let isImapReady = false
|
||||
app.set('isImapReady', false)
|
||||
|
|
@ -141,13 +274,17 @@ debug('Bound IMAP deleted mail event handler')
|
|||
mailProcessingService.on('error', err => {
|
||||
debug('Fatal error from mail processing service:', err.message)
|
||||
console.error('Error from mailProcessingService, stopping.', err)
|
||||
if (!config.uxDebugMode) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
imapService.on(ImapService.EVENT_ERROR, error => {
|
||||
debug('Fatal error from IMAP service:', error.message)
|
||||
console.error('Fatal error from IMAP service', error)
|
||||
if (!config.uxDebugMode) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
app.set('mailProcessingService', mailProcessingService)
|
||||
|
|
@ -156,11 +293,18 @@ app.set('config', config)
|
|||
app.locals.imapService = imapService
|
||||
app.locals.mailProcessingService = mailProcessingService
|
||||
|
||||
debug('Starting IMAP connection and message loading')
|
||||
if (config.uxDebugMode) {
|
||||
debug('Starting Mock Mail Service (UX Debug Mode)')
|
||||
} else {
|
||||
debug('Starting IMAP connection and message loading')
|
||||
}
|
||||
|
||||
imapService.connectAndLoadMessages().catch(error => {
|
||||
debug('Failed to connect to IMAP:', error.message)
|
||||
console.error('Fatal error from IMAP service', error)
|
||||
debug('Failed to connect:', error.message)
|
||||
console.error('Fatal error from mail service', error)
|
||||
if (!config.uxDebugMode) {
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
server.on('error', error => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
require("dotenv").config({ quiet: true });
|
||||
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.
|
||||
* Returns `undefined` if the value is missing or invalid.
|
||||
|
|
@ -30,8 +37,11 @@ function parseBool(v) {
|
|||
}
|
||||
|
||||
const config = {
|
||||
// UX Debug Mode
|
||||
uxDebugMode: parseBool(process.env.UX_DEBUG_MODE) || false,
|
||||
|
||||
email: {
|
||||
domains: parseValue(process.env.EMAIL_DOMAINS),
|
||||
domains: parseValue(process.env.EMAIL_DOMAINS) || [],
|
||||
purgeTime: {
|
||||
time: Number(process.env.EMAIL_PURGE_TIME),
|
||||
unit: parseValue(process.env.EMAIL_PURGE_UNIT),
|
||||
|
|
@ -41,7 +51,10 @@ const config = {
|
|||
account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT),
|
||||
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: {
|
||||
|
|
@ -66,12 +79,19 @@ const config = {
|
|||
},
|
||||
|
||||
http: {
|
||||
// Server settings
|
||||
port: Number(process.env.HTTP_PORT),
|
||||
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),
|
||||
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
||||
displaySort: Number(process.env.HTTP_DISPLAY_SORT) || 0,
|
||||
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: {
|
||||
|
|
@ -81,9 +101,6 @@ const config = {
|
|||
// Database
|
||||
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
|
||||
maxForwardEmails: Number(process.env.USER_MAX_FORWARD_EMAILS) || 5,
|
||||
maxLockedInboxes: Number(process.env.USER_MAX_LOCKED_INBOXES) || 5,
|
||||
|
|
@ -93,9 +110,26 @@ const config = {
|
|||
|
||||
// validation
|
||||
debug('Validating configuration...')
|
||||
if (!config.imap.user || !config.imap.password || !config.imap.host) {
|
||||
|
||||
// Skip IMAP validation in UX debug mode
|
||||
if (!config.uxDebugMode) {
|
||||
if (!config.imap.user || !config.imap.password || !config.imap.host) {
|
||||
debug('IMAP configuration validation failed: missing user, password, or host')
|
||||
throw new Error("IMAP is not configured. Check IMAP_* env vars.");
|
||||
}
|
||||
}
|
||||
|
||||
// In UX debug mode, provide default domain if none configured
|
||||
if (config.uxDebugMode && !config.email.domains.length) {
|
||||
config.email.domains = ['ux-debug.local']
|
||||
debug('UX Debug Mode: Using default domain "ux-debug.local"')
|
||||
}
|
||||
|
||||
// In UX debug mode, set example account to show mock emails
|
||||
if (config.uxDebugMode) {
|
||||
config.email.examples.account = `demo@${config.email.domains[0]}`
|
||||
config.email.examples.uids = [1, 2]
|
||||
debug(`UX Debug Mode: Example account set to ${config.email.examples.account} with UIDs 1, 2`)
|
||||
}
|
||||
|
||||
if (!config.email.domains.length) {
|
||||
|
|
@ -103,6 +137,6 @@ if (!config.email.domains.length) {
|
|||
throw new Error("No EMAIL_DOMAINS configured.");
|
||||
}
|
||||
|
||||
debug(`Configuration validated successfully: ${config.email.domains.length} domains, IMAP host: ${config.imap.host}`)
|
||||
debug(`Configuration validated successfully: ${config.email.domains.length} domains${config.uxDebugMode ? ' (UX DEBUG MODE)' : ''}`)
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
|||
|
|
@ -109,11 +109,10 @@ class Helper {
|
|||
/**
|
||||
* Build a mail count html element with tooltip for the footer
|
||||
* @param {number} count - Current mail count
|
||||
* @param {number} largestUid - Largest UID from IMAP
|
||||
* @returns {String}
|
||||
*/
|
||||
mailCountBuilder(count) {
|
||||
const imapService = require('./imap-service')
|
||||
const largestUid = imapService.getLargestUid ? imapService.getLargestUid() : null
|
||||
mailCountBuilder(count, largestUid = null) {
|
||||
let tooltip = ''
|
||||
|
||||
if (largestUid && largestUid > 0) {
|
||||
|
|
@ -156,10 +155,10 @@ class Helper {
|
|||
* @returns {Array}
|
||||
*/
|
||||
hideOther(array) {
|
||||
if (config.http.hideOther) {
|
||||
return array[0]
|
||||
if (config.http.features.hideOther) {
|
||||
return array && array.length > 0 ? [array[0]] : []
|
||||
} else {
|
||||
return array
|
||||
return array || []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,9 +167,13 @@ class Helper {
|
|||
* @returns {Array}
|
||||
*/
|
||||
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;
|
||||
switch (config.http.displaySort) {
|
||||
switch (config.http.features.displaySort) {
|
||||
case 0:
|
||||
result = this.hideOther(config.email.domains) // No modification
|
||||
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
|
||||
debug(`Domain sort 3: shuffle all, ${result.length} domains`)
|
||||
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)
|
||||
*/
|
||||
signCookie(email) {
|
||||
const secret = config.user.sessionSecret
|
||||
const secret = config.http.sessionSecret
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
hmac.update(email.toLowerCase())
|
||||
const signature = hmac.digest('hex')
|
||||
|
|
|
|||
|
|
@ -301,8 +301,6 @@ class ImapService extends EventEmitter {
|
|||
toDelete.forEach(uid => {
|
||||
this.emit(ImapService.EVENT_DELETED_MAIL, uid);
|
||||
});
|
||||
|
||||
console.log(`Deleted ${toDelete.length} old messages.`);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -140,10 +140,8 @@ class MailProcessingService extends EventEmitter {
|
|||
onInitialLoadDone() {
|
||||
this.initialLoadDone = true
|
||||
debug('Initial load completed, total mails:', this.mailRepository.mailCount())
|
||||
console.log(`Initial load done, got ${this.mailRepository.mailCount()} mails`)
|
||||
console.log(`Fetching and deleting mails every ${this.config.imap.refreshIntervalSeconds} seconds`)
|
||||
console.log(`Mails older than ${this.config.email.purgeTime.time} ${this.config.email.purgeTime.unit} will be deleted`)
|
||||
console.log(`The example emails are: ${this.config.email.examples.uids.join(', ')}, on the account ${this.config.email.examples.account}`)
|
||||
|
||||
// Don't print old-style logs here, app.js will handle the startup banner
|
||||
}
|
||||
|
||||
onNewMail(mail) {
|
||||
|
|
@ -274,7 +272,7 @@ class MailProcessingService extends EventEmitter {
|
|||
|
||||
// Forward via SMTP service
|
||||
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)
|
||||
|
||||
if (result.success) {
|
||||
|
|
@ -354,7 +352,7 @@ class MailProcessingService extends EventEmitter {
|
|||
|
||||
// Send verification email
|
||||
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}`)
|
||||
const result = await this.smtpService.sendVerificationEmail(
|
||||
|
|
|
|||
37
application/mocks/mock-auth-service.js
Normal file
37
application/mocks/mock-auth-service.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Mock Auth Service for UX Debug Mode
|
||||
* Provides dummy authentication without database
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
class MockAuthService {
|
||||
constructor() {
|
||||
// Mock user data
|
||||
this.mockUser = {
|
||||
id: 1,
|
||||
username: 'demo',
|
||||
password_hash: 'mock', // Any password works
|
||||
created_at: Date.now() - 86400000, // 1 day ago
|
||||
last_login: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
// Accept any username/password in debug mode
|
||||
return {
|
||||
success: true,
|
||||
user: this.mockUser
|
||||
}
|
||||
}
|
||||
|
||||
async register(username, password) {
|
||||
// Accept any registration
|
||||
return {
|
||||
success: true,
|
||||
user: this.mockUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockAuthService
|
||||
125
application/mocks/mock-inbox-lock.js
Normal file
125
application/mocks/mock-inbox-lock.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Mock Inbox Lock for UX Debug Mode
|
||||
* Provides dummy inbox locking without database
|
||||
*/
|
||||
|
||||
const debug = require('debug')('48hr-email:mock-inbox-lock')
|
||||
|
||||
class MockInboxLock {
|
||||
constructor(mockUserRepository) {
|
||||
this.mockUserRepository = mockUserRepository
|
||||
this.locks = new Map()
|
||||
|
||||
// Initialize locks from repository
|
||||
this._initializeLocks()
|
||||
|
||||
debug(`Mock locked inboxes: ${Array.from(mockUserRepository.lockedInboxes).join(', ')}`)
|
||||
}
|
||||
|
||||
_initializeLocks() {
|
||||
// Add the mock locked inboxes from the repository
|
||||
for (const address of this.mockUserRepository.lockedInboxes) {
|
||||
this.locks.set(address.toLowerCase(), {
|
||||
userId: 1,
|
||||
address: address.toLowerCase(),
|
||||
lockedAt: Date.now(),
|
||||
lastAccess: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to initial state (called when repository resets)
|
||||
reset() {
|
||||
this.locks.clear()
|
||||
this._initializeLocks()
|
||||
debug('Mock inbox locks reset to initial state')
|
||||
}
|
||||
|
||||
isLocked(address) {
|
||||
return this.locks.has(address.toLowerCase())
|
||||
}
|
||||
|
||||
hasAccess(userId, address) {
|
||||
const lock = this.locks.get(address.toLowerCase())
|
||||
if (!lock) return true // Not locked
|
||||
return lock.userId === userId
|
||||
}
|
||||
|
||||
isLockedByUser(address, userId) {
|
||||
const lock = this.locks.get(address.toLowerCase())
|
||||
if (!lock) return false
|
||||
return lock.userId === userId
|
||||
}
|
||||
|
||||
lock(userId, address) {
|
||||
const normalizedAddress = address.toLowerCase()
|
||||
if (this.locks.has(normalizedAddress)) {
|
||||
throw new Error('Inbox is already locked')
|
||||
}
|
||||
|
||||
this.locks.set(normalizedAddress, {
|
||||
userId,
|
||||
address: normalizedAddress,
|
||||
lockedAt: Date.now(),
|
||||
lastAccess: Date.now()
|
||||
})
|
||||
|
||||
this.mockUserRepository.lockedInboxes.add(address)
|
||||
debug(`Locked inbox: ${normalizedAddress}`)
|
||||
return true
|
||||
}
|
||||
|
||||
release(userId, address) {
|
||||
const normalizedAddress = address.toLowerCase()
|
||||
const lock = this.locks.get(normalizedAddress)
|
||||
|
||||
if (!lock) {
|
||||
throw new Error('Inbox is not locked')
|
||||
}
|
||||
|
||||
if (lock.userId !== userId) {
|
||||
throw new Error('You do not own this lock')
|
||||
}
|
||||
|
||||
this.locks.delete(normalizedAddress)
|
||||
this.mockUserRepository.lockedInboxes.delete(address)
|
||||
debug(`Released lock on ${normalizedAddress}`)
|
||||
return true
|
||||
}
|
||||
|
||||
updateAccess(userId, address) {
|
||||
const lock = this.locks.get(address.toLowerCase())
|
||||
if (lock && lock.userId === userId) {
|
||||
lock.lastAccess = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
getUserLockedInboxes(userId) {
|
||||
const userLocks = []
|
||||
for (const [address, lock] of this.locks.entries()) {
|
||||
if (lock.userId === userId) {
|
||||
userLocks.push({
|
||||
address: address,
|
||||
locked_at: lock.lockedAt,
|
||||
last_access: lock.lastAccess
|
||||
})
|
||||
}
|
||||
}
|
||||
return userLocks
|
||||
}
|
||||
|
||||
getInactive(hours) {
|
||||
// Mock - return empty array
|
||||
return []
|
||||
}
|
||||
|
||||
getUserLockCount(userId) {
|
||||
let count = 0
|
||||
for (const lock of this.locks.values()) {
|
||||
if (lock.userId === userId) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockInboxLock
|
||||
364
application/mocks/mock-mail-service.js
Normal file
364
application/mocks/mock-mail-service.js
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* Mock Mail Service for UX Debug Mode
|
||||
* Provides sample emails without requiring IMAP/SMTP connections
|
||||
*/
|
||||
|
||||
const Mail = require('../../domain/mail')
|
||||
const EventEmitter = require('events')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Clara's PGP Public Key
|
||||
const CLARA_PGP_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQENBGb92JcBCADNMGkl6x2e//Prbbyvlb3EE6BwfOdKpSa+70bJ8fsudlkas5VN
|
||||
Iyuq6Kmuk8V9LD5qBL3e0SMe2x3K5xb+j0Wim/n0OIHkbdnAOtLqEbYMSAzL3keo
|
||||
mqw5qbV47js3rxht9BZ2HYZm5GqOqLz4XuIomSS/EsDcuQDKVtKveE2nRkJUIORr
|
||||
C+DNFcjgJc3yrF1bKE3KQz2ii7qNRH/ChKRXB+OS/7ZviQOSTlFVPGhjIxaI2sRI
|
||||
Uw8U8pWPYyQzh+dpiA3OmzbF1/BB2AQOx98p975KTI4wmalF5PtsKnkFFZ1NPKC6
|
||||
E6G0IIbDkEE1HBpEO4qmIuWd/tFyIP03EwL3ABEBAAG0G0NsYXJhIEsgPGNsYXJh
|
||||
QGNyYXp5Y28ueHl6PokBSgQQAQgAHQUCZv3YlwQLCQcIAxUICgQWAAIBAhkBAhsD
|
||||
Ah4BACEJEAGLYq6lsVaPFiEEmKN22IQaxMpTgI1sAYtirqWxVo8IOAf9HJglE8hQ
|
||||
bqGtbCISKGOkeIq8TFr9A2MRaevNVQtf4o9TnzMi+9nFGfi6yniiceBz9oUWoXvt
|
||||
ZkhEzc0Hn6PAX/sOW3r6wPu5gSaGjUJfd339aDasyZvdOoQ4cukcErIaFnAG7KmP
|
||||
0Q7lyRp5K7dUmuc9b9dg5ngf+M8306dj/djHWCPtsaLJc7ExrfeT1lNA7MeY7DlE
|
||||
9jdvm4hfwQZND16nXKKLZn/RZUkR5Zoo1LE+GSL0/GCFZeH1PnEt5kcI3QKyx9wn
|
||||
+DlMcAZCVs2X5JzTbJQKr9Cwv1syOlaZmVeUTuKiTfAB71wINQkFHdmONIg0h9wp
|
||||
ThTjXOlDsQvnP7kBDQRm/diXAQgAg8BaBpL//o62UrrbQ79WbVzVTH+2f+xVD8bE
|
||||
tyEL1QjllFfPdc5oT9nQ5RPfN6IJpbN0/p688pQa10gFgjEN0WtI51Vda/PQ1FQ8
|
||||
q1xXbH6zJXP3FAPEPTId4Rw7Gb+vaUaBo3O0ZyKpAxzEy2gIvXz2ChfL6ENn5QZ/
|
||||
1DsBeQQE3YbgG+jXAL//JGjINoppOTCfnEMlKaZYdkLvA2KiJKqtD+JDTVFkdk02
|
||||
1Jext8Td6wkd72i0+DQI9RaJJr5oDXlxAN0iX4OMSdo35e2Mj4AktjvO8JzRvZjU
|
||||
uPCGYH9DpVoB0OCNRmD/2CeUyQgiehk8NHXLxf8h1duTGZYYRQARAQABiQE2BBgB
|
||||
CAAJBQJm/diXAhsMACEJEAGLYq6lsVaPFiEEmKN22IQaxMpTgI1sAYtirqWxVo/R
|
||||
cQgAmJ0taRUkOmoepQR6JNJejF1JiITQy5eSvZzXDBoEWip4fcl4FRAy5yz6s/sC
|
||||
NtweWyWMg3+lu+s7qh3r1Qw5EN7ukgUy+fvk6xY3TBxcJ1aC/KvKbaeTrZt0Bt6U
|
||||
sQipNDI/cPkL2ILzqt/shEgj9g/EWARe1X5SQ0nEhCYLi7xZV9lBe3dU+EUlmwSe
|
||||
gmxppMfACd9hyVV4SbO6l5NKmXgkYWNMzFzjfg3pxAPuJjaaYN85XETqpKwdfPRt
|
||||
KUPuyh+UdOt8GPRBcFxjRJQrBRw2nBJxCCEJOJAJJ2ySpHQBwpaXsK0WW2SGkaxF
|
||||
ggOCb56KkepgTvU3Xdv5opRZAg==
|
||||
=HEe7
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
class MockMailService extends EventEmitter {
|
||||
constructor(config) {
|
||||
super()
|
||||
this.config = config
|
||||
this.mockEmails = this._generateMockEmails()
|
||||
this.logoAttachment = this._getLogoAttachment()
|
||||
}
|
||||
|
||||
_getLogoAttachment() {
|
||||
// Try to read the service logo
|
||||
const logoPath = path.join(__dirname, '../infrastructure/web/public/images/logo.png')
|
||||
if (fs.existsSync(logoPath)) {
|
||||
return {
|
||||
filename: '48hr-email-logo.png',
|
||||
content: fs.readFileSync(logoPath),
|
||||
contentType: 'image/png'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_generateMockEmails() {
|
||||
const domain = this.config.email.domains[0]
|
||||
const now = new Date()
|
||||
const earlier = new Date(now.getTime() - 3600000) // 1 hour ago
|
||||
|
||||
return [{
|
||||
mail: Mail.create(
|
||||
[`demo@${domain}`], [{ name: 'Clara K', address: 'clara@crazyco.xyz' }],
|
||||
earlier.toISOString(),
|
||||
'Welcome to 48hr.email - Plain Text Demo',
|
||||
1
|
||||
),
|
||||
fullMail: {
|
||||
text: `Hello from 48hr.email!
|
||||
|
||||
This is a plain text demonstration email for UX debugging purposes.
|
||||
|
||||
48hr.email is your favorite open-source temporary email service, created by ClaraCrazy.
|
||||
|
||||
Features:
|
||||
- Disposable email addresses
|
||||
- No registration required
|
||||
- Auto-delete after configured time
|
||||
- Open source (GPL-3.0)
|
||||
- Self-hostable
|
||||
|
||||
For more information, visit: https://48hr.email
|
||||
GitHub: https://github.com/Crazyco-xyz/48hr.email
|
||||
Discord: https://discord.gg/crazyco
|
||||
|
||||
---
|
||||
Clara's PGP Public Key:
|
||||
|
||||
${CLARA_PGP_KEY}
|
||||
|
||||
---
|
||||
|
||||
This is a mock email generated for UX debug mode.
|
||||
No actual IMAP or SMTP connections were used.`,
|
||||
textAsHtml: `<p>Hello from 48hr.email!</p>
|
||||
<p>This is a plain text demonstration email for UX debugging purposes.</p>
|
||||
<p>48hr.email is your favorite open-source temporary email service, created by ClaraCrazy.</p>
|
||||
<p>Features:<br/>
|
||||
- Disposable email addresses<br/>
|
||||
- No registration required<br/>
|
||||
- Auto-delete after configured time<br/>
|
||||
- Open source (GPL-3.0)<br/>
|
||||
- Self-hostable</p>
|
||||
<p>For more information, visit: <a href="https://48hr.email">https://48hr.email</a><br/>
|
||||
GitHub: <a href="https://github.com/Crazyco-xyz/48hr.email">https://github.com/Crazyco-xyz/48hr.email</a><br/>
|
||||
Discord: <a href="https://discord.gg/crazyco">https://discord.gg/crazyco</a></p>
|
||||
<p>---<br/>
|
||||
Clara's PGP Public Key:</p>
|
||||
<pre style="background: #1a1a1a; color: #666; padding: 12px; border-radius: 6px; border: 1px solid rgba(155, 77, 202, 0.2);">${CLARA_PGP_KEY}</pre>
|
||||
<p>---</p>
|
||||
<p>This is a mock email generated for UX debug mode.<br/>
|
||||
No actual IMAP or SMTP connections were used.</p>`,
|
||||
html: null,
|
||||
subject: 'Welcome to 48hr.email - Plain Text Demo',
|
||||
from: { text: 'Clara K <clara@crazyco.xyz>' },
|
||||
to: { text: `demo@${domain}` },
|
||||
date: earlier,
|
||||
attachments: this.logoAttachment ? [this.logoAttachment] : []
|
||||
}
|
||||
},
|
||||
{
|
||||
mail: Mail.create(
|
||||
[`demo@${domain}`], [{ name: '48hr.email', address: 'noreply@48hr.email' }],
|
||||
now.toISOString(),
|
||||
'HTML Email Demo - Features Overview',
|
||||
2
|
||||
),
|
||||
fullMail: {
|
||||
text: `48hr.email - HTML Email Demo
|
||||
|
||||
This is the plain text version of the HTML email.
|
||||
|
||||
Visit https://48hr.email for more information.
|
||||
|
||||
Clara's PGP Key is attached to this email.`,
|
||||
html: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #e0e0e0;
|
||||
background: #131516;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid rgba(155, 77, 202, 0.3);
|
||||
}
|
||||
h1 {
|
||||
color: #9b4dca;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
h2 {
|
||||
color: #9b4dca;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #cccccc;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
.feature-item {
|
||||
padding: 6px 10px;
|
||||
background: rgba(155, 77, 202, 0.08);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: #cccccc;
|
||||
}
|
||||
.pgp-key {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(155, 77, 202, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #666;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
line-height: 1.3;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
a {
|
||||
color: #9b4dca;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: #b366e6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>48hr.email</h1>
|
||||
<p class="subtitle">Temporary inbox, no registration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="section">
|
||||
<h2>About</h2>
|
||||
<p>Open-source temporary email service. Create disposable addresses instantly and receive emails without registration. Emails auto-delete after the configured purge time.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Features</h2>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">Instant addresses</div>
|
||||
<div class="feature-item">No registration</div>
|
||||
<div class="feature-item">Real-time updates</div>
|
||||
<div class="feature-item">HTML rendering</div>
|
||||
<div class="feature-item">Open source GPL-3.0</div>
|
||||
<div class="feature-item">Self-hostable</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Developer PGP Key (ClaraCrazy)</h2>
|
||||
<div class="pgp-key">${CLARA_PGP_KEY}</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>48hr.email</strong> by <a href="https://crazyco.xyz">ClaraCrazy</a> · <a href="https://github.com/Crazyco-xyz/48hr.email">GitHub</a> · <a href="https://discord.gg/crazyco">Discord</a> · UX Debug Mode</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
subject: 'HTML Email Demo - Features Overview',
|
||||
from: { text: '48hr.email <noreply@48hr.email>' },
|
||||
to: { text: `demo@${domain}` },
|
||||
date: now,
|
||||
attachments: this.logoAttachment ? [this.logoAttachment] : []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async connectAndLoadMessages() {
|
||||
// Simulate async loading
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Emit initial load event
|
||||
this.emit('initial load done')
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
getMockEmails() {
|
||||
return this.mockEmails
|
||||
}
|
||||
|
||||
async fetchOneFullMail(to, uid, raw = false) {
|
||||
const email = this.mockEmails.find(e => e.mail.uid === parseInt(uid))
|
||||
if (!email) {
|
||||
throw new Error(`Mock email with UID ${uid} not found`)
|
||||
}
|
||||
|
||||
// If raw is requested, return a string representation
|
||||
if (raw) {
|
||||
const mail = email.fullMail
|
||||
const headers = [
|
||||
`From: ${mail.from.text}`,
|
||||
`To: ${mail.to.text}`,
|
||||
`Date: ${mail.date}`,
|
||||
`Subject: ${mail.subject}`,
|
||||
`Content-Type: ${mail.html ? 'text/html; charset=UTF-8' : 'text/plain; charset=UTF-8'}`,
|
||||
'',
|
||||
mail.html || mail.text || ''
|
||||
]
|
||||
return headers.join('\n')
|
||||
}
|
||||
|
||||
return email.fullMail
|
||||
}
|
||||
|
||||
// Stub methods for compatibility
|
||||
deleteMessage() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
deleteOldMails() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
closeBox() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
getSecondsUntilNextRefresh() {
|
||||
// In mock mode, return null (no refresh needed)
|
||||
return null
|
||||
}
|
||||
|
||||
async getLargestUid() {
|
||||
// Return the largest UID from mock emails
|
||||
const mockEmails = this.getMockEmails()
|
||||
if (mockEmails.length === 0) return null
|
||||
return Math.max(...mockEmails.map(e => e.mail.uid))
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
return super.on(event, handler)
|
||||
}
|
||||
}
|
||||
|
||||
MockMailService.EVENT_INITIAL_LOAD_DONE = 'initial load done'
|
||||
MockMailService.EVENT_NEW_MAIL = 'mail'
|
||||
MockMailService.EVENT_DELETED_MAIL = 'mailDeleted'
|
||||
MockMailService.EVENT_ERROR = 'error'
|
||||
|
||||
module.exports = MockMailService
|
||||
131
application/mocks/mock-user-repository.js
Normal file
131
application/mocks/mock-user-repository.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Mock User Repository for UX Debug Mode
|
||||
* Provides dummy user data without database
|
||||
*/
|
||||
|
||||
const debug = require('debug')('48hr-email:mock-user-repo')
|
||||
|
||||
class MockUserRepository {
|
||||
constructor(config) {
|
||||
this.config = config
|
||||
|
||||
// Generate a random forwarding email (fixed for this server instance)
|
||||
const randomGmailName = Math.random().toString(36).substring(2, 10)
|
||||
this.mockForwardEmail = `${randomGmailName}@gmail.com`
|
||||
|
||||
// Generate a random locked inbox (fixed for this server instance)
|
||||
const randomWords = ['alpha', 'beta', 'gamma', 'delta', 'omega', 'sigma', 'theta']
|
||||
const word1 = randomWords[Math.floor(Math.random() * randomWords.length)]
|
||||
const word2 = randomWords[Math.floor(Math.random() * randomWords.length)]
|
||||
const num = Math.floor(Math.random() * 999)
|
||||
this.mockLockedInbox = `${word1}${word2}${num}@${config.email.domains[0]}`
|
||||
|
||||
// Store the initial values to reset to
|
||||
this.initialForwardEmail = this.mockForwardEmail
|
||||
this.initialLockedInbox = this.mockLockedInbox
|
||||
|
||||
// In-memory storage that can be modified during a session
|
||||
this.forwardEmails = new Set([this.mockForwardEmail])
|
||||
this.lockedInboxes = new Set([this.mockLockedInbox])
|
||||
|
||||
debug(`Mock forward email: ${this.mockForwardEmail}`)
|
||||
debug(`Mock locked inbox: ${this.mockLockedInbox}`)
|
||||
}
|
||||
|
||||
// Reset to initial state (called on new page loads)
|
||||
reset() {
|
||||
this.forwardEmails = new Set([this.initialForwardEmail])
|
||||
this.lockedInboxes = new Set([this.initialLockedInbox])
|
||||
debug('Mock data reset to initial state')
|
||||
}
|
||||
|
||||
// User methods
|
||||
getUserById(userId) {
|
||||
if (userId === 1) {
|
||||
return {
|
||||
id: 1,
|
||||
username: 'demo',
|
||||
password_hash: 'mock',
|
||||
created_at: Date.now() - 86400000,
|
||||
last_login: Date.now()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
getUserByUsername(username) {
|
||||
return this.getUserById(1)
|
||||
}
|
||||
|
||||
updateLastLogin(userId) {
|
||||
// No-op in mock
|
||||
return true
|
||||
}
|
||||
|
||||
// Forward email methods
|
||||
getForwardEmails(userId) {
|
||||
if (userId === 1) {
|
||||
const emails = []
|
||||
let id = 1
|
||||
for (const email of this.forwardEmails) {
|
||||
emails.push({
|
||||
id: id++,
|
||||
user_id: 1,
|
||||
email: email,
|
||||
verified: true,
|
||||
verification_token: null,
|
||||
created_at: Date.now() - 3600000
|
||||
})
|
||||
}
|
||||
return emails
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
addForwardEmail(userId, email, token) {
|
||||
this.forwardEmails.add(email)
|
||||
return {
|
||||
id: this.forwardEmails.size,
|
||||
user_id: userId,
|
||||
email: email,
|
||||
verified: false,
|
||||
verification_token: token,
|
||||
created_at: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
verifyForwardEmail(token) {
|
||||
// In mock mode, just return success
|
||||
return true
|
||||
}
|
||||
|
||||
removeForwardEmail(userId, email) {
|
||||
const deleted = this.forwardEmails.delete(email)
|
||||
debug(`Removed forward email: ${email} (success: ${deleted})`)
|
||||
return deleted
|
||||
}
|
||||
|
||||
deleteForwardEmail(userId, email) {
|
||||
// Alias for removeForwardEmail
|
||||
return this.removeForwardEmail(userId, email)
|
||||
}
|
||||
|
||||
// User stats
|
||||
getUserStats(userId, config) {
|
||||
return {
|
||||
lockedInboxesCount: this.lockedInboxes.size,
|
||||
forwardEmailsCount: this.forwardEmails.size,
|
||||
accountAge: Math.floor((Date.now() - (Date.now() - 86400000)) / 86400000),
|
||||
maxLockedInboxes: config.maxLockedInboxes || 5,
|
||||
maxForwardEmails: config.maxForwardEmails || 5,
|
||||
lockReleaseHours: config.lockReleaseHours || 720
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup - no-op
|
||||
close() {
|
||||
debug('Mock user repository closed')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MockUserRepository
|
||||
|
|
@ -309,8 +309,8 @@ class StatisticsStore {
|
|||
// Calculate emails per hour rate (average across all active hours)
|
||||
const activeHours = hourlyActivity.filter(count => count > 0).length
|
||||
const emailsPerHour = activeHours > 0 ?
|
||||
(allMails.length / activeHours).toFixed(1) :
|
||||
'0.0'
|
||||
Math.round(allMails.length / activeHours) :
|
||||
0
|
||||
|
||||
// Calculate day/night percentage
|
||||
const totalDayNight = dayTimeEmails + nightTimeEmails
|
||||
|
|
@ -327,7 +327,7 @@ class StatisticsStore {
|
|||
uniqueSenderDomains: senderDomains.size,
|
||||
uniqueRecipientDomains: recipientDomains.size,
|
||||
peakHourPercentage,
|
||||
emailsPerHour: parseFloat(emailsPerHour),
|
||||
emailsPerHour: emailsPerHour,
|
||||
dayPercentage
|
||||
}
|
||||
|
||||
|
|
@ -455,23 +455,23 @@ class StatisticsStore {
|
|||
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
|
||||
|
||||
// Aggregate by hour
|
||||
const hourlyBuckets = new Map()
|
||||
// Aggregate by 15-minute intervals for better granularity
|
||||
const intervalBuckets = new Map()
|
||||
relevantHistory.forEach(point => {
|
||||
const hour = Math.floor(point.timestamp / 3600000) * 3600000
|
||||
if (!hourlyBuckets.has(hour)) {
|
||||
hourlyBuckets.set(hour, 0)
|
||||
const interval = Math.floor(point.timestamp / 900000) * 900000 // 15 minutes
|
||||
if (!intervalBuckets.has(interval)) {
|
||||
intervalBuckets.set(interval, 0)
|
||||
}
|
||||
hourlyBuckets.set(hour, hourlyBuckets.get(hour) + point.receives)
|
||||
intervalBuckets.set(interval, intervalBuckets.get(interval) + point.receives)
|
||||
})
|
||||
|
||||
// Convert to array and sort
|
||||
const hourlyData = Array.from(hourlyBuckets.entries())
|
||||
const intervalData = Array.from(intervalBuckets.entries())
|
||||
.map(([timestamp, receives]) => ({ timestamp, receives }))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
debug(`Historical timeline: ${hourlyData.length} hourly points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
|
||||
return hourlyData
|
||||
debug(`Historical timeline: ${intervalData.length} 15-min interval points within ${config.email.purgeTime.time} ${config.email.purgeTime.unit} window`)
|
||||
return intervalData
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -512,12 +512,16 @@ class StatisticsStore {
|
|||
|
||||
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
|
||||
|
||||
// Generate predictions for purge duration (in 1-hour intervals)
|
||||
// Generate predictions for a reasonable future window
|
||||
// Limit to 20% of purge duration or 12 hours max to maintain chart balance
|
||||
// Use 15-minute intervals for better granularity
|
||||
const purgeMs = this._getPurgeCutoffMs()
|
||||
const predictionHours = Math.ceil(purgeMs / (60 * 60 * 1000))
|
||||
const purgeDurationHours = Math.ceil(purgeMs / (60 * 60 * 1000))
|
||||
const predictionHours = Math.min(12, Math.ceil(purgeDurationHours * 0.2))
|
||||
const predictionIntervals = predictionHours * 4 // Convert hours to 15-min intervals
|
||||
|
||||
for (let i = 1; i <= predictionHours; i++) {
|
||||
const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals
|
||||
for (let i = 1; i <= predictionIntervals; i++) {
|
||||
const timestamp = now + (i * 15 * 60 * 1000) // 15 minute intervalsals
|
||||
const futureDate = new Date(timestamp)
|
||||
const futureHour = futureDate.getHours()
|
||||
|
||||
|
|
@ -529,8 +533,8 @@ class StatisticsStore {
|
|||
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
|
||||
}
|
||||
|
||||
// baseCount is already per-minute average, scale to full hour
|
||||
const scaledCount = baseCount * 60
|
||||
// baseCount is already per-minute average, scale to 15 minutes
|
||||
const scaledCount = baseCount * 15
|
||||
|
||||
// Add randomization (±20%)
|
||||
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
|
||||
|
|
@ -626,23 +630,23 @@ class StatisticsStore {
|
|||
_getTimeline() {
|
||||
const now = Date.now()
|
||||
const cutoff = now - this._getPurgeCutoffMs()
|
||||
const hourly = {}
|
||||
const buckets = {}
|
||||
|
||||
// Aggregate by hour
|
||||
// Aggregate by 15-minute intervals for better granularity
|
||||
this.hourlyData
|
||||
.filter(e => e.timestamp >= cutoff)
|
||||
.forEach(entry => {
|
||||
const hour = Math.floor(entry.timestamp / 3600000) * 3600000
|
||||
if (!hourly[hour]) {
|
||||
hourly[hour] = { timestamp: hour, receives: 0, deletes: 0, forwards: 0 }
|
||||
const interval = Math.floor(entry.timestamp / 900000) * 900000 // 15 minutes
|
||||
if (!buckets[interval]) {
|
||||
buckets[interval] = { timestamp: interval, receives: 0, deletes: 0, forwards: 0 }
|
||||
}
|
||||
hourly[hour].receives += entry.receives
|
||||
hourly[hour].deletes += entry.deletes
|
||||
hourly[hour].forwards += entry.forwards
|
||||
buckets[interval].receives += entry.receives
|
||||
buckets[interval].deletes += entry.deletes
|
||||
buckets[interval].forwards += entry.forwards
|
||||
})
|
||||
|
||||
// Convert to sorted array
|
||||
return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp)
|
||||
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
const templateContext = require('../template-context')
|
||||
|
||||
function checkLockAccess(req, res, next) {
|
||||
const inboxLock = req.app.get('inboxLock')
|
||||
const address = req.params.address
|
||||
|
|
@ -21,14 +23,10 @@ function checkLockAccess(req, res, next) {
|
|||
const unlockError = req.session ? req.session.unlockError : undefined
|
||||
if (req.session) delete req.session.unlockError
|
||||
|
||||
return res.render('error', {
|
||||
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
|
||||
address: address,
|
||||
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
|
||||
})
|
||||
return res.render('error', templateContext.build(req, {
|
||||
title: 'Access Denied',
|
||||
message: 'This inbox is locked by another user. Only the owner can access it.'
|
||||
}))
|
||||
}
|
||||
|
||||
// Update last access if they have access and are authenticated
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
* 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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const chartCanvas = document.getElementById('statsChart');
|
||||
|
|
@ -10,7 +17,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
// Get data from global variables (set by template)
|
||||
if (typeof window.initialStatsData === 'undefined') {
|
||||
console.error('Initial stats data not found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -18,29 +24,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const historicalData = window.historicalData || [];
|
||||
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
|
||||
// Set up Socket.IO connection for real-time updates with rate limiting
|
||||
if (typeof io !== 'undefined') {
|
||||
const socket = io();
|
||||
|
||||
socket.on('stats-update', () => {
|
||||
console.log('Stats update received (page will not auto-reload)');
|
||||
// Don't auto-reload - user can manually refresh if needed
|
||||
});
|
||||
|
||||
socket.on('reconnect', () => {
|
||||
console.log('Reconnected to server');
|
||||
const now = Date.now();
|
||||
if (now - lastReloadTime >= RELOAD_COOLDOWN_MS) {
|
||||
lastReloadTime = now;
|
||||
reloadStatsData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Combine all data and create labels
|
||||
const now = Date.now();
|
||||
|
||||
// Use a reasonable historical window (show data within the purge time range)
|
||||
// This will adapt based on whether purge time is 48 hours, 7 days, etc.
|
||||
const allTimePoints = [
|
||||
...historicalData.map(d => ({...d, type: 'historical' })),
|
||||
// Merge historical and realtime into a single continuous dataset
|
||||
// Historical will be blue, current will be green
|
||||
// Only show historical data that doesn't overlap with realtime (exclude any matching timestamps)
|
||||
const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp));
|
||||
const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp));
|
||||
|
||||
allTimePoints = [
|
||||
...filteredHistorical.map(d => ({...d, type: 'historical' })),
|
||||
...realtimeData.map(d => ({...d, type: 'realtime' })),
|
||||
...predictionData.map(d => ({...d, type: 'prediction' }))
|
||||
].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
|
@ -56,46 +63,53 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
});
|
||||
|
||||
// Prepare datasets
|
||||
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
|
||||
const realtimePoints = allTimePoints.map(d => d.type === 'realtime' ? d.receives : null);
|
||||
// Merge historical and realtime into one dataset with segment coloring
|
||||
const combinedPoints = allTimePoints.map(d =>
|
||||
(d.type === 'historical' || d.type === 'realtime') ? d.receives : null
|
||||
);
|
||||
const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null);
|
||||
|
||||
// Create gradient for fading effect on historical data
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
const historicalGradient = ctx.createLinearGradient(0, 0, chartCanvas.width * 0.3, 0);
|
||||
historicalGradient.addColorStop(0, 'rgba(100, 100, 255, 0.05)');
|
||||
historicalGradient.addColorStop(1, 'rgba(100, 100, 255, 0.15)');
|
||||
chartContext = ctx;
|
||||
|
||||
// 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',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Historical',
|
||||
data: historicalPoints,
|
||||
borderColor: 'rgba(100, 149, 237, 0.8)',
|
||||
backgroundColor: historicalGradient,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: 'rgba(100, 149, 237, 0.8)',
|
||||
spanGaps: true,
|
||||
fill: true,
|
||||
hidden: false
|
||||
label: 'Email Activity',
|
||||
data: combinedPoints,
|
||||
segment: {
|
||||
borderColor: (context) => {
|
||||
const index = context.p0DataIndex;
|
||||
const point = allTimePoints[index];
|
||||
// Blue for historical, green for current
|
||||
return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
|
||||
},
|
||||
{
|
||||
label: 'Current Activity',
|
||||
data: realtimePoints,
|
||||
borderColor: '#2ecc71',
|
||||
backgroundColor: (context) => {
|
||||
const index = context.p0DataIndex;
|
||||
const point = allTimePoints[index];
|
||||
return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.15)' : 'rgba(46, 204, 113, 0.15)';
|
||||
}
|
||||
},
|
||||
borderColor: '#2ecc71', // Default to green
|
||||
backgroundColor: 'rgba(46, 204, 113, 0.15)',
|
||||
borderWidth: 4,
|
||||
borderWidth: 3,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#2ecc71',
|
||||
pointRadius: (context) => {
|
||||
const index = context.dataIndex;
|
||||
const point = allTimePoints[index];
|
||||
return point && point.type === 'historical' ? 3 : 4;
|
||||
},
|
||||
pointBackgroundColor: (context) => {
|
||||
const index = context.dataIndex;
|
||||
const point = allTimePoints[index];
|
||||
return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71';
|
||||
},
|
||||
spanGaps: true,
|
||||
fill: true,
|
||||
hidden: false
|
||||
|
|
@ -132,7 +146,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
if (!context || !context[0] || context[0].dataIndex === undefined) return '';
|
||||
const dataIndex = context[0].dataIndex;
|
||||
if (!allTimePoints[dataIndex]) return '';
|
||||
const point = allTimePoints[dataIndex];
|
||||
const date = new Date(point.timestamp);
|
||||
return date.toLocaleString('en-US', {
|
||||
|
|
@ -193,14 +209,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
legendContainer.className = 'chart-legend-custom';
|
||||
legendContainer.innerHTML = `
|
||||
<button class="legend-btn active" data-index="0">
|
||||
<span class="legend-indicator" style="background: rgba(100, 149, 237, 0.8);"></span>
|
||||
<span class="legend-label">Historical</span>
|
||||
<span class="legend-indicator" style="background: linear-gradient(to right, rgba(100, 149, 237, 0.8) 0%, #2ecc71 100%);"></span>
|
||||
<span class="legend-label">Email Activity</span>
|
||||
</button>
|
||||
<button class="legend-btn active" data-index="1">
|
||||
<span class="legend-indicator" style="background: #2ecc71;"></span>
|
||||
<span class="legend-label">Current Activity</span>
|
||||
</button>
|
||||
<button class="legend-btn active" data-index="2">
|
||||
<span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
|
||||
<span class="legend-label">Predicted</span>
|
||||
</button>
|
||||
|
|
@ -218,8 +230,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
this.classList.toggle('active');
|
||||
|
||||
// Toggle dataset visibility with fade effect
|
||||
const meta = chart.getDatasetMeta(index);
|
||||
const dataset = chart.data.datasets[index];
|
||||
const meta = statsChart.getDatasetMeta(index);
|
||||
const dataset = statsChart.data.datasets[index];
|
||||
|
||||
if (isActive) {
|
||||
// Fade out
|
||||
|
|
@ -231,7 +243,187 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,8 +498,76 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
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
|
||||
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();
|
||||
formatMailDate();
|
||||
initLockModals();
|
||||
|
|
@ -509,4 +577,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
initThemeToggle();
|
||||
initForwardModal();
|
||||
initCryptoKeysToggle();
|
||||
initAccountModals();
|
||||
initRawTabs();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1842,6 +1842,9 @@ label {
|
|||
border: 1px solid var(--overlay-white-10);
|
||||
width: 100%;
|
||||
min-height: 40vh;
|
||||
max-height: 100vh;
|
||||
resize: vertical;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mail-text-content {
|
||||
|
|
@ -2115,6 +2118,7 @@ label {
|
|||
}
|
||||
|
||||
.close {
|
||||
text-align: right;
|
||||
float: right;
|
||||
font-size: 2.8rem;
|
||||
font-weight: bold;
|
||||
|
|
@ -2887,7 +2891,7 @@ body.light-mode .theme-icon-light {
|
|||
}
|
||||
.action-dropdown .dropdown-menu a {
|
||||
padding: 10px 0;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
border: none !important;
|
||||
}
|
||||
.action-dropdown .dropdown-menu a:hover {
|
||||
|
|
@ -2924,7 +2928,7 @@ body.light-mode .theme-icon-light {
|
|||
.action-links.mobile-open>button:not(.hamburger-menu) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
|
|
|||
|
|
@ -3,16 +3,33 @@ const express = require('express')
|
|||
const router = express.Router()
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
const { body, validationResult } = require('express-validator')
|
||||
const templateContext = require('../template-context')
|
||||
|
||||
// GET /account - Account dashboard
|
||||
router.get('/account', requireAuth, async(req, res) => {
|
||||
try {
|
||||
const config = req.app.get('config')
|
||||
const userRepository = req.app.get('userRepository')
|
||||
const inboxLock = req.app.get('inboxLock')
|
||||
const mailProcessingService = req.app.get('mailProcessingService')
|
||||
const Helper = require('../../../application/helper')
|
||||
const helper = new Helper()
|
||||
|
||||
// In UX debug mode, reset mock data to initial state only on fresh page load
|
||||
// (not on redirects after form submissions)
|
||||
if (config.uxDebugMode && userRepository && userRepository.reset) {
|
||||
// Check if this is a redirect from a form submission
|
||||
const isRedirect = req.session.accountSuccess || req.session.accountError
|
||||
|
||||
if (!isRedirect) {
|
||||
// This is a fresh page load, reset to initial state
|
||||
userRepository.reset()
|
||||
if (inboxLock && inboxLock.reset) {
|
||||
inboxLock.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's verified forwarding emails
|
||||
const forwardEmails = userRepository.getForwardEmails(req.session.userId)
|
||||
|
||||
|
|
@ -23,27 +40,22 @@ router.get('/account', requireAuth, async(req, res) => {
|
|||
}
|
||||
|
||||
// Get user stats
|
||||
const config = req.app.get('config')
|
||||
const stats = userRepository.getUserStats(req.session.userId, config.user)
|
||||
|
||||
// Get purge time for footer
|
||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||
const successMessage = req.session.accountSuccess
|
||||
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',
|
||||
username: req.session.username,
|
||||
forwardEmails,
|
||||
lockedInboxes,
|
||||
stats,
|
||||
branding: config.http.branding,
|
||||
purgeTime: purgeTime,
|
||||
successMessage: req.session.accountSuccess,
|
||||
errorMessage: req.session.accountError
|
||||
})
|
||||
|
||||
// Clear flash messages
|
||||
delete req.session.accountSuccess
|
||||
delete req.session.accountError
|
||||
successMessage,
|
||||
errorMessage
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Account page error:', error)
|
||||
res.status(500).render('error', {
|
||||
|
|
@ -94,7 +106,7 @@ router.post('/account/forward-email/add',
|
|||
|
||||
// Send verification email
|
||||
const baseUrl = config.http.baseUrl
|
||||
const branding = config.http.branding[0]
|
||||
const branding = (config.http.features.branding || ['48hr.email'])[0]
|
||||
|
||||
await smtpService.sendVerificationEmail(
|
||||
email,
|
||||
|
|
@ -228,6 +240,13 @@ router.post('/account/change-password',
|
|||
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
|
||||
async(req, res) => {
|
||||
try {
|
||||
const config = req.app.get('config')
|
||||
// Block password change in UX debug mode
|
||||
if (config.uxDebugMode) {
|
||||
req.session.accountError = 'Password changes are disabled in UX debug mode'
|
||||
return res.redirect('/account')
|
||||
}
|
||||
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
req.session.accountError = errors.array()[0].msg
|
||||
|
|
@ -281,6 +300,13 @@ router.post('/account/delete',
|
|||
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
|
||||
async(req, res) => {
|
||||
try {
|
||||
const config = req.app.get('config')
|
||||
// Block account deletion in UX debug mode
|
||||
if (config.uxDebugMode) {
|
||||
req.session.accountError = 'Account deletion is disabled in UX debug mode'
|
||||
return res.redirect('/account')
|
||||
}
|
||||
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
req.session.accountError = errors.array()[0].msg
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ const { body, validationResult } = require('express-validator')
|
|||
const debug = require('debug')('48hr-email:auth-routes')
|
||||
const { redirectIfAuthenticated } = require('../middleware/auth')
|
||||
const config = require('../../../application/config')
|
||||
const Helper = require('../../../application/helper')
|
||||
const helper = new Helper()
|
||||
|
||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||
const templateContext = require('../template-context')
|
||||
|
||||
// Simple in-memory rate limiters for registration and login
|
||||
const registrationRateLimitStore = new Map()
|
||||
|
|
@ -79,23 +76,30 @@ const loginRateLimiter = (req, res, 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)
|
||||
router.get('/auth', redirectIfAuthenticated, (req, res) => {
|
||||
const config = req.app.get('config')
|
||||
const errorMessage = req.session.errorMessage
|
||||
const successMessage = req.session.successMessage
|
||||
|
||||
// Clear messages after reading
|
||||
delete req.session.errorMessage
|
||||
delete req.session.successMessage
|
||||
|
||||
res.render('auth', {
|
||||
title: `Login or Register | ${config.http.branding[0]}`,
|
||||
branding: config.http.branding,
|
||||
purgeTime: purgeTime,
|
||||
errorMessage,
|
||||
res.render('auth', templateContext.build(req, {
|
||||
title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`,
|
||||
successMessage
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
// POST /register - Process registration
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
const express = require('express')
|
||||
|
||||
const router = new express.Router()
|
||||
const config = require('../../../application/config')
|
||||
const Helper = require('../../../application/helper')
|
||||
const helper = new(Helper)
|
||||
const templateContext = require('../template-context')
|
||||
const debug = require('debug')('48hr-email:routes')
|
||||
|
||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||
|
||||
router.get('/:address/:errorCode', async(req, res, next) => {
|
||||
try {
|
||||
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'
|
||||
|
||||
debug(`Rendering error page ${errorCode} with message: ${message}`)
|
||||
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
|
||||
res.status(errorCode)
|
||||
res.render('error', {
|
||||
title: `${config.http.branding[0]} | ${errorCode}`,
|
||||
purgeTime: purgeTime,
|
||||
address: req.params.address,
|
||||
res.render('error', templateContext.build(req, {
|
||||
title: `${branding[0]} | ${errorCode}`,
|
||||
message: message,
|
||||
status: errorCode,
|
||||
branding: config.http.branding
|
||||
})
|
||||
status: errorCode
|
||||
}))
|
||||
} catch (error) {
|
||||
debug('Error loading error page:', error.message)
|
||||
console.error('Error while loading error page', error)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const debug = require('debug')('48hr-email:routes')
|
|||
const config = require('../../../application/config')
|
||||
const Helper = require('../../../application/helper')
|
||||
const CryptoDetector = require('../../../application/crypto-detector')
|
||||
const templateContext = require('../template-context')
|
||||
const helper = new(Helper)
|
||||
const cryptoDetector = new CryptoDetector()
|
||||
const { checkLockAccess } = require('../middleware/lock')
|
||||
|
|
@ -105,67 +106,11 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
|
|||
throw new Error('Mail processing service not available')
|
||||
}
|
||||
debug(`Inbox request for ${req.params.address}`)
|
||||
const inboxLock = req.app.get('inboxLock')
|
||||
|
||||
// Check lock status
|
||||
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 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
|
||||
})
|
||||
res.render('inbox', templateContext.build(req, {
|
||||
title: `${(config.http.features.branding || ['48hr.email'])[0]} | ` + req.params.address,
|
||||
mailSummaries: mailProcessingService.getMailSummaries(req.params.address)
|
||||
}))
|
||||
} catch (error) {
|
||||
debug(`Error loading inbox for ${req.params.address}:`, error.message)
|
||||
console.error('Error while loading inbox', error)
|
||||
|
|
@ -200,57 +145,13 @@ router.get(
|
|||
const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.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}`)
|
||||
res.render('mail', {
|
||||
res.render('mail', templateContext.build(req, {
|
||||
title: mail.subject + " | " + req.params.address,
|
||||
purgeTime: purgeTime,
|
||||
address: req.params.address,
|
||||
mail,
|
||||
cryptoAttachments: cryptoAttachments,
|
||||
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
|
||||
})
|
||||
uid: req.params.uid
|
||||
}))
|
||||
} else {
|
||||
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!'
|
||||
|
|
@ -422,11 +323,11 @@ router.get(
|
|||
// Emails are immutable, cache if found
|
||||
res.set('Cache-Control', 'private, max-age=600')
|
||||
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,
|
||||
mail: rawMail,
|
||||
decoded: decodedMail
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
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!'
|
||||
|
|
@ -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)
|
||||
router.post(
|
||||
'^/:address/:uid/forward',
|
||||
requireAuth,
|
||||
smtpEnabled,
|
||||
forwardLimiter,
|
||||
validateDomain,
|
||||
checkLockAccess,
|
||||
|
|
@ -503,6 +415,7 @@ router.post(
|
|||
router.post(
|
||||
'^/:address/forward-all',
|
||||
requireAuth,
|
||||
smtpEnabled,
|
||||
forwardLimiter,
|
||||
validateDomain,
|
||||
checkLockAccess,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,9 @@ const express = require('express')
|
|||
const router = new express.Router()
|
||||
const { check, validationResult } = require('express-validator')
|
||||
const debug = require('debug')('48hr-email:routes')
|
||||
|
||||
const randomWord = require('random-word')
|
||||
const config = require('../../../application/config')
|
||||
const Helper = require('../../../application/helper')
|
||||
const helper = new(Helper)
|
||||
|
||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||
const templateContext = require('../template-context')
|
||||
|
||||
router.get('/', async(req, res, next) => {
|
||||
try {
|
||||
|
|
@ -17,14 +13,12 @@ router.get('/', async(req, res, next) => {
|
|||
throw new Error('Mail processing service not available')
|
||||
}
|
||||
debug('Login page requested')
|
||||
const context = templateContext.build(req, {
|
||||
username: randomWord()
|
||||
})
|
||||
res.render('login', {
|
||||
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
||||
username: randomWord(),
|
||||
purgeTime: purgeTime,
|
||||
purgeTimeRaw: config.email.purgeTime,
|
||||
domains: helper.getDomains(),
|
||||
branding: config.http.branding,
|
||||
example: config.email.examples.account,
|
||||
...context,
|
||||
title: `${context.branding[0]} | Your temporary Inbox`
|
||||
})
|
||||
} catch (error) {
|
||||
debug('Error loading login page:', error.message)
|
||||
|
|
@ -56,14 +50,13 @@ router.post(
|
|||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
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,
|
||||
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
||||
purgeTime: purgeTime,
|
||||
purgeTimeRaw: config.email.purgeTime,
|
||||
username: randomWord(),
|
||||
domains: helper.getDomains(),
|
||||
branding: config.http.branding,
|
||||
username: randomWord()
|
||||
})
|
||||
return res.render('login', {
|
||||
...context,
|
||||
title: `${context.branding[0]} | Your temporary Inbox`
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,101 @@
|
|||
const express = require('express')
|
||||
const router = new express.Router()
|
||||
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) => {
|
||||
try {
|
||||
const config = req.app.get('config')
|
||||
|
||||
// Check if statistics are enabled
|
||||
if (!config.http.statisticsEnabled) {
|
||||
return res.status(404).send('Statistics are disabled')
|
||||
if (!config.http.features.statistics) {
|
||||
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')
|
||||
|
|
@ -32,45 +118,13 @@ router.get('/', async(req, res) => {
|
|||
}
|
||||
|
||||
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`)
|
||||
|
||||
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()
|
||||
debug(`Stats API returned: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total`)
|
||||
|
||||
res.json(stats)
|
||||
} catch (error) {
|
||||
debug(`Error fetching stats API: ${error.message}`)
|
||||
console.error('Stats API error:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch statistics' })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
115
infrastructure/web/template-context.js
Normal file
115
infrastructure/web/template-context.js
Normal 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()
|
||||
|
|
@ -44,10 +44,12 @@
|
|||
<div class="account-card frosted-glass">
|
||||
<h2>Account Overview</h2>
|
||||
<div class="stats-grid">
|
||||
{% if smtpEnabled %}
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div>
|
||||
<div class="stat-label">Forward Emails</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div>
|
||||
<div class="stat-label">Locked Inboxes</div>
|
||||
|
|
@ -59,6 +61,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if smtpEnabled %}
|
||||
<!-- Forwarding Emails Section -->
|
||||
<div class="account-card frosted-glass">
|
||||
<h2>Forwarding Emails</h2>
|
||||
|
|
@ -91,6 +94,7 @@
|
|||
<p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Locked Inboxes Section -->
|
||||
<div class="account-card frosted-glass">
|
||||
|
|
@ -178,7 +182,7 @@
|
|||
<div class="danger-content">
|
||||
<p><strong>Warning:</strong> Deleting your account will:</p>
|
||||
<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>Permanently delete your account data</li>
|
||||
</ul>
|
||||
|
|
@ -226,6 +230,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if smtpEnabled %}
|
||||
<!-- Add Email Modal -->
|
||||
<div id="addEmailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
|
@ -249,56 +254,5 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -123,9 +123,11 @@
|
|||
<div class="auth-features-unified">
|
||||
<h3>Account Benefits</h3>
|
||||
<div class="features-grid">
|
||||
{% if smtpEnabled %}
|
||||
<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>
|
||||
{% 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>
|
||||
<p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
|
||||
<div class="dropdown-menu" data-section-title="Inbox Actions">
|
||||
{% if smtpEnabled %}
|
||||
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
||||
{% endif %}
|
||||
{% if authEnabled %}
|
||||
{% if isLocked and hasAccess %}
|
||||
<a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a>
|
||||
|
|
@ -17,9 +19,7 @@
|
|||
{% endif %}
|
||||
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Dropdown (logged in) -->
|
||||
</div> <!-- Account Dropdown (logged in) -->
|
||||
{% if authEnabled %}
|
||||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||
|
|
@ -178,6 +178,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Forward All Modal -->
|
||||
{% if smtpEnabled %}
|
||||
<div id="forwardAllModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<span class="close" id="closeForwardAll">×</span>
|
||||
|
|
@ -216,4 +217,5 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -9,27 +9,27 @@
|
|||
|
||||
{% block metaTags %}
|
||||
<!-- 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="author" content="CrazyCo">
|
||||
<meta name="author" content="{{ branding.1 }}">
|
||||
<meta name="robots" 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 -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://48hr.email/">
|
||||
<meta property="og:title" content="48hr.email - 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:image" content="https://48hr.email/images/logo.png">
|
||||
<meta property="og:site_name" content="48hr.email">
|
||||
<meta property="og:url" content="{{ config.http.baseUrl }}/">
|
||||
<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 {{ purgeTimeRaw | readablePurgeTime }}.">
|
||||
<meta property="og:image" content="{{ config.http.baseUrl }}/images/logo.png">
|
||||
<meta property="og:site_name" content="{{ branding.0 }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:url" content="https://48hr.email/">
|
||||
<meta name="twitter:title" content="48hr.email - Your temporary Inbox">
|
||||
<meta name="twitter:url" content="{{ config.http.baseUrl }}/">
|
||||
<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:image" content="https://48hr.email/images/logo.png">
|
||||
<meta name="twitter:image" content="{{ config.http.baseUrl }}/images/logo.png">
|
||||
{% endblock %}
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
|
|
@ -88,13 +88,20 @@
|
|||
</a>
|
||||
{% block header %}{% endblock %}
|
||||
</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 %}
|
||||
</main>
|
||||
|
||||
{% block footer %}
|
||||
<section class="container footer">
|
||||
<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>
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -89,10 +89,16 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{% if showInfoSection %}
|
||||
<div class="info-section">
|
||||
<div class="features-grid">
|
||||
<div class="feature-card frosted-glass">
|
||||
<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 class="feature-card frosted-glass">
|
||||
<h3>Instant Access</h3>
|
||||
|
|
@ -104,7 +110,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-content frosted-glass">
|
||||
<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>
|
||||
|
|
@ -129,8 +134,9 @@
|
|||
<li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li>
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
<div class="action-dropdown">
|
||||
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
|
||||
<div class="dropdown-menu" data-section-title="Email Actions">
|
||||
{% if smtpEnabled %}
|
||||
<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 }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
||||
</div>
|
||||
|
|
@ -127,6 +129,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Forward Email Modal -->
|
||||
{% if smtpEnabled %}
|
||||
<div id="forwardModal" class="modal" style="display: none;">
|
||||
<div class="modal-content frosted-glass">
|
||||
<span class="close" id="closeForward">×</span>
|
||||
|
|
@ -165,6 +168,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,19 +13,6 @@
|
|||
<pre class="raw-mail hidden" data-panel="decoded">{{ decoded | e }}</pre>
|
||||
</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 %}
|
||||
|
||||
{% block footer %}{% endblock %}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,22 @@
|
|||
|
||||
{% block metaTags %}
|
||||
<!-- 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 -->
|
||||
<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:description" content="{{ stats.currentCount }} emails in system | {{ stats.allTimeTotal }} all-time total | Real-time monitoring and predictions">
|
||||
<meta property="og:image" content="https://48hr.email/images/logo.png">
|
||||
<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="{{ config.http.baseUrl }}/images/logo.png">
|
||||
<meta property="og:site_name" content="{{ branding.0 }}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<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:description" content="{{ stats.currentCount }} emails | {{ stats.allTimeTotal }} all-time | Live monitoring">
|
||||
<meta name="twitter:image" content="https://48hr.email/images/logo.png">
|
||||
<meta name="twitter:description" content="{{ metaStats.currentCount }} emails | {{ metaStats.allTimeTotal }} all-time | Live monitoring">
|
||||
<meta name="twitter:image" content="{{ config.http.baseUrl }}/images/logo.png">
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
|
|
@ -96,19 +96,19 @@
|
|||
<h3 class="section-header-small">
|
||||
Top Sender Domains
|
||||
</h3>
|
||||
<ul class="stat-list" data-stats="top-sender-domains">
|
||||
{% if stats.enhanced.topSenderDomains|length > 0 %}
|
||||
<ul class="stat-list">
|
||||
{% for item in stats.enhanced.topSenderDomains|slice(0, 5) %}
|
||||
<li class="stat-list-item">
|
||||
<span class="stat-list-label">{{ item.domain }}</span>
|
||||
<span class="stat-list-value">{{ item.count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="stat-footer">{{ stats.enhanced.uniqueSenderDomains }} unique domains</p>
|
||||
{% else %}
|
||||
<p class="stat-empty">No data yet</p>
|
||||
<li class="stat-empty">Loading...</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p class="stat-footer"><span data-stats="unique-sender-domains">{{ stats.enhanced.uniqueSenderDomains }}</span> unique domains</p>
|
||||
</div>
|
||||
|
||||
<!-- Top Recipient Domains -->
|
||||
|
|
@ -116,19 +116,19 @@
|
|||
<h3 class="section-header-small">
|
||||
Top Recipient Domains
|
||||
</h3>
|
||||
<ul class="stat-list" data-stats="top-recipient-domains">
|
||||
{% if stats.enhanced.topRecipientDomains|length > 0 %}
|
||||
<ul class="stat-list">
|
||||
{% for item in stats.enhanced.topRecipientDomains|slice(0, 5) %}
|
||||
<li class="stat-list-item">
|
||||
<span class="stat-list-label">{{ item.domain }}</span>
|
||||
<span class="stat-list-value">{{ item.count }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="stat-footer">{{ stats.enhanced.uniqueRecipientDomains }} unique domains</p>
|
||||
{% else %}
|
||||
<p class="stat-empty">No data yet</p>
|
||||
<li class="stat-empty">Loading...</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p class="stat-footer"><span data-stats="unique-recipient-domains">{{ stats.enhanced.uniqueRecipientDomains }}</span> unique domains</p>
|
||||
</div>
|
||||
|
||||
<!-- Busiest Hours -->
|
||||
|
|
@ -136,18 +136,18 @@
|
|||
<h3 class="section-header-small">
|
||||
Busiest Hours
|
||||
</h3>
|
||||
<ul class="stat-list" data-stats="busiest-hours">
|
||||
{% if stats.enhanced.busiestHours|length > 0 %}
|
||||
<ul class="stat-list">
|
||||
{% for item in stats.enhanced.busiestHours %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="stat-empty">No data yet</p>
|
||||
<li class="stat-empty">Loading...</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
|
|
@ -157,27 +157,27 @@
|
|||
</h3>
|
||||
<div class="quick-stats">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,12 +71,7 @@ function convertAndRound(time, unit) {
|
|||
*/
|
||||
exports.readablePurgeTime = function(purgeTime) {
|
||||
if (!purgeTime || !purgeTime.time || !purgeTime.unit) {
|
||||
// Fallback to config if not provided
|
||||
if (config.email.purgeTime) {
|
||||
purgeTime = config.email.purgeTime
|
||||
} else {
|
||||
return '48 hours'
|
||||
}
|
||||
}
|
||||
|
||||
let result = `${purgeTime.time} ${purgeTime.unit}`
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@ const lockRouter = require('./routes/lock')
|
|||
const authRouter = require('./routes/auth')
|
||||
const accountRouter = require('./routes/account')
|
||||
const statsRouter = require('./routes/stats')
|
||||
const templateContext = require('./template-context')
|
||||
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
|
||||
const handleRouteError = (error, req, res, next, context = 'route') => {
|
||||
debug(`Error in ${context}:`, error.message)
|
||||
|
|
@ -40,16 +37,21 @@ const server = http.createServer(app)
|
|||
const io = socketio(server)
|
||||
|
||||
app.set('socketio', io)
|
||||
app.use(logger('dev'))
|
||||
|
||||
// HTTP request logging - only enable with DEBUG environment variable
|
||||
if (process.env.DEBUG && process.env.DEBUG.includes('48hr-email')) {
|
||||
app.use(logger('dev'))
|
||||
}
|
||||
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: false }))
|
||||
|
||||
// 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)
|
||||
app.use(session({
|
||||
secret: config.user.sessionSecret,
|
||||
secret: config.http.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
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.config = config
|
||||
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) {
|
||||
res.locals.currentUser = {
|
||||
id: req.session.userId,
|
||||
|
|
@ -107,14 +116,25 @@ app.use((req, res, next) => {
|
|||
})
|
||||
|
||||
// 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 imapService = req.app.get('imapService')
|
||||
const Helper = require('../../application/helper')
|
||||
const helper = new Helper()
|
||||
|
||||
if (mailProcessingService) {
|
||||
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 {
|
||||
res.locals.mailCount = ''
|
||||
}
|
||||
|
|
@ -125,7 +145,9 @@ app.use((req, res, next) => {
|
|||
app.use((req, res, next) => {
|
||||
const isImapReady = req.app.get('isImapReady')
|
||||
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()
|
||||
})
|
||||
|
|
@ -156,11 +178,11 @@ app.use(async(err, req, res, _next) => {
|
|||
|
||||
// Render the error page
|
||||
res.status(err.status || 500)
|
||||
res.render('error', {
|
||||
purgeTime: purgeTime,
|
||||
address: req.params && req.params.address,
|
||||
branding: config.http.branding
|
||||
})
|
||||
res.render('error', templateContext.build(req, {
|
||||
title: 'Error',
|
||||
message: err.message,
|
||||
status: err.status || 500
|
||||
}))
|
||||
} catch (renderError) {
|
||||
debug('Error in error handler:', renderError.message)
|
||||
console.error('Critical error in error handler', renderError)
|
||||
|
|
@ -183,6 +205,9 @@ server.on('listening', () => {
|
|||
const addr = server.address()
|
||||
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
|
||||
debug('Listening on ' + bind)
|
||||
|
||||
// Emit event for app.js to display startup banner
|
||||
server.emit('ready')
|
||||
})
|
||||
|
||||
module.exports = { app, io, server }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "48hr.email",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"private": false,
|
||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||
"keywords": [
|
||||
|
|
@ -27,7 +27,9 @@
|
|||
"scripts": {
|
||||
"start": "node --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": {
|
||||
"async-retry": "^1.3.3",
|
||||
|
|
|
|||
22
scripts/check-domains.js
Normal file
22
scripts/check-domains.js
Normal 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
150
scripts/check-env.js
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue