mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-02-14 17:19:35 +01:00
Compare commits
9 commits
69011624a7
...
fa0dc27cba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa0dc27cba | ||
|
|
3c7482b23a | ||
|
|
c3fea6a70b | ||
|
|
a078abae00 | ||
|
|
c11a82f42b | ||
|
|
c56ec92ce5 | ||
|
|
2f58eacfa7 | ||
|
|
3fdf5bf61b | ||
|
|
e012b772c8 |
35 changed files with 2449 additions and 209 deletions
|
|
@ -17,7 +17,7 @@ IMAP_USER="user@example.com" # IMAP username
|
||||||
IMAP_PASSWORD="password" # IMAP password
|
IMAP_PASSWORD="password" # IMAP password
|
||||||
IMAP_SERVER="imap.example.com" # IMAP server address
|
IMAP_SERVER="imap.example.com" # IMAP server address
|
||||||
IMAP_PORT=993 # IMAP port (default 993)
|
IMAP_PORT=993 # IMAP port (default 993)
|
||||||
IMAP_TLS=true # Use secure TLS connection (true/false)
|
IMAP_SECURE=true # Use secure TLS connection (true/false)
|
||||||
IMAP_AUTH_TIMEOUT=3000 # Authentication timeout in ms
|
IMAP_AUTH_TIMEOUT=3000 # Authentication timeout in ms
|
||||||
IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interval for checking new emails
|
IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interval for checking new emails
|
||||||
IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load
|
IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load
|
||||||
|
|
@ -25,11 +25,11 @@ IMAP_CONCURRENCY=6 # Number of conc
|
||||||
|
|
||||||
# --- SMTP CONFIGURATION (for email forwarding) ---
|
# --- SMTP CONFIGURATION (for email forwarding) ---
|
||||||
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
|
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
|
||||||
SMTP_HOST="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
|
|
||||||
SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)
|
|
||||||
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports)
|
|
||||||
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
|
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
|
||||||
SMTP_PASSWORD="password" # SMTP authentication password
|
SMTP_PASSWORD="password" # SMTP authentication password
|
||||||
|
SMTP_SERVER="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
|
||||||
|
SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)
|
||||||
|
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports)
|
||||||
|
|
||||||
# --- HTTP / WEB CONFIGURATION ---
|
# --- HTTP / WEB CONFIGURATION ---
|
||||||
HTTP_PORT=3000 # Port
|
HTTP_PORT=3000 # Port
|
||||||
|
|
@ -41,6 +41,7 @@ HTTP_DISPLAY_SORT=2 # Domain display
|
||||||
# 2 = alphabetical + first item shuffled,
|
# 2 = alphabetical + first item shuffled,
|
||||||
# 3 = shuffle all
|
# 3 = shuffle all
|
||||||
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
|
||||||
|
HTTP_STATISTICS_ENABLED=false # Enable statistics page at /stats (true/false)
|
||||||
|
|
||||||
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
# --- USER AUTHENTICATION & INBOX LOCKING ---
|
||||||
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
|
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
|
||||||
|
|
|
||||||
BIN
.github/assets/stats.png
vendored
Normal file
BIN
.github/assets/stats.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
|
|
@ -34,6 +34,7 @@ All data is being removed 48hrs after they have reached the mail server.
|
||||||
- View the raw email, showing all the headers etc.
|
- View the raw email, showing all the headers etc.
|
||||||
- Download Attachments with one click
|
- Download Attachments with one click
|
||||||
- <u>Optional</u> User Account System with email forwarding and inbox locking
|
- <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...
|
- and more...
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
@ -42,9 +43,9 @@ All data is being removed 48hrs after they have reached the mail server.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
| Homepage | Account Panel |
|
| Homepage | Account Panel | Stats Page |
|
||||||
|:---:|:---:|
|
|:---:|:---:|:---:|
|
||||||
| <img src=".github/assets/home.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/account.png" width="500px" height="300px" style="object-fit: fit;"> |
|
| <img src=".github/assets/home.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/account.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/stats.png" width="500px" height="300px" style="object-fit: fit;"> |
|
||||||
|
|
||||||
| Inbox | Email using HTML and CSS | Attachments and Cryptographic Keys view |
|
| Inbox | Email using HTML and CSS | Attachments and Cryptographic Keys view |
|
||||||
|:---:|:---:|:---:|
|
|:---:|:---:|:---:|
|
||||||
|
|
|
||||||
29
app.js
29
app.js
|
|
@ -5,7 +5,7 @@
|
||||||
const config = require('./application/config')
|
const config = require('./application/config')
|
||||||
const debug = require('debug')('48hr-email:app')
|
const debug = require('debug')('48hr-email:app')
|
||||||
const Helper = require('./application/helper')
|
const Helper = require('./application/helper')
|
||||||
|
const helper = new(Helper)
|
||||||
const { app, io, server } = require('./infrastructure/web/web')
|
const { app, io, server } = require('./infrastructure/web/web')
|
||||||
const ClientNotification = require('./infrastructure/web/client-notification')
|
const ClientNotification = require('./infrastructure/web/client-notification')
|
||||||
const ImapService = require('./application/imap-service')
|
const ImapService = require('./application/imap-service')
|
||||||
|
|
@ -16,6 +16,7 @@ const MailRepository = require('./domain/mail-repository')
|
||||||
const InboxLock = require('./domain/inbox-lock')
|
const InboxLock = require('./domain/inbox-lock')
|
||||||
const VerificationStore = require('./domain/verification-store')
|
const VerificationStore = require('./domain/verification-store')
|
||||||
const UserRepository = require('./domain/user-repository')
|
const UserRepository = require('./domain/user-repository')
|
||||||
|
const StatisticsStore = require('./domain/statistics-store')
|
||||||
|
|
||||||
const clientNotification = new ClientNotification()
|
const clientNotification = new ClientNotification()
|
||||||
debug('Client notification service initialized')
|
debug('Client notification service initialized')
|
||||||
|
|
@ -34,6 +35,8 @@ app.set('config', config)
|
||||||
|
|
||||||
// Initialize user repository and auth service (if enabled)
|
// Initialize user repository and auth service (if enabled)
|
||||||
let inboxLock = null
|
let inboxLock = null
|
||||||
|
let statisticsStore = null
|
||||||
|
|
||||||
if (config.user.authEnabled) {
|
if (config.user.authEnabled) {
|
||||||
// Migrate legacy database files for backwards compatibility
|
// Migrate legacy database files for backwards compatibility
|
||||||
Helper.migrateDatabase(config.user.databasePath)
|
Helper.migrateDatabase(config.user.databasePath)
|
||||||
|
|
@ -42,6 +45,11 @@ if (config.user.authEnabled) {
|
||||||
debug('User repository initialized')
|
debug('User repository initialized')
|
||||||
app.set('userRepository', userRepository)
|
app.set('userRepository', userRepository)
|
||||||
|
|
||||||
|
// Initialize statistics store with database connection
|
||||||
|
statisticsStore = new StatisticsStore(userRepository.db)
|
||||||
|
debug('Statistics store initialized with database persistence')
|
||||||
|
app.set('statisticsStore', statisticsStore)
|
||||||
|
|
||||||
const authService = new AuthService(userRepository, config)
|
const authService = new AuthService(userRepository, config)
|
||||||
debug('Auth service initialized')
|
debug('Auth service initialized')
|
||||||
app.set('authService', authService)
|
app.set('authService', authService)
|
||||||
|
|
@ -69,6 +77,11 @@ if (config.user.authEnabled) {
|
||||||
|
|
||||||
console.log('User authentication system enabled')
|
console.log('User authentication system enabled')
|
||||||
} else {
|
} else {
|
||||||
|
// No auth enabled - initialize statistics store without persistence
|
||||||
|
statisticsStore = new StatisticsStore()
|
||||||
|
debug('Statistics store initialized (in-memory only, no database)')
|
||||||
|
app.set('statisticsStore', statisticsStore)
|
||||||
|
|
||||||
app.set('userRepository', null)
|
app.set('userRepository', null)
|
||||||
app.set('authService', null)
|
app.set('authService', null)
|
||||||
app.set('inboxLock', null)
|
app.set('inboxLock', null)
|
||||||
|
|
@ -84,10 +97,22 @@ const mailProcessingService = new MailProcessingService(
|
||||||
clientNotification,
|
clientNotification,
|
||||||
config,
|
config,
|
||||||
smtpService,
|
smtpService,
|
||||||
verificationStore
|
verificationStore,
|
||||||
|
statisticsStore
|
||||||
)
|
)
|
||||||
debug('Mail processing service initialized')
|
debug('Mail processing service initialized')
|
||||||
|
|
||||||
|
// Initialize statistics with current count
|
||||||
|
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, async() => {
|
||||||
|
const count = mailProcessingService.getCount()
|
||||||
|
statisticsStore.initialize(count)
|
||||||
|
|
||||||
|
// Get and set the largest UID for all-time total
|
||||||
|
const largestUid = await helper.getLargestUid(imapService)
|
||||||
|
statisticsStore.updateLargestUid(largestUid)
|
||||||
|
debug(`Statistics initialized with ${count} emails, largest UID: ${largestUid}`)
|
||||||
|
})
|
||||||
|
|
||||||
// Set up timer sync broadcasting after IMAP is ready
|
// Set up timer sync broadcasting after IMAP is ready
|
||||||
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
|
imapService.on(ImapService.EVENT_INITIAL_LOAD_DONE, () => {
|
||||||
clientNotification.startTimerSync(imapService)
|
clientNotification.startTimerSync(imapService)
|
||||||
|
|
|
||||||
2
app.json
2
app.json
|
|
@ -44,7 +44,7 @@
|
||||||
"description": "Port of the server (usually 993)",
|
"description": "Port of the server (usually 993)",
|
||||||
"value": 993
|
"value": 993
|
||||||
},
|
},
|
||||||
"IMAP_TLS": {
|
"IMAP_SECURE": {
|
||||||
"description": "Use tls or not",
|
"description": "Use tls or not",
|
||||||
"value": true
|
"value": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const config = {
|
||||||
password: parseValue(process.env.IMAP_PASSWORD),
|
password: parseValue(process.env.IMAP_PASSWORD),
|
||||||
host: parseValue(process.env.IMAP_SERVER),
|
host: parseValue(process.env.IMAP_SERVER),
|
||||||
port: Number(process.env.IMAP_PORT),
|
port: Number(process.env.IMAP_PORT),
|
||||||
tls: parseBool(process.env.IMAP_TLS),
|
secure: parseBool(process.env.IMAP_SECURE),
|
||||||
authTimeout: Number(process.env.IMAP_AUTH_TIMEOUT),
|
authTimeout: Number(process.env.IMAP_AUTH_TIMEOUT),
|
||||||
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
|
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
|
||||||
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
|
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
|
||||||
|
|
@ -58,11 +58,11 @@ const config = {
|
||||||
|
|
||||||
smtp: {
|
smtp: {
|
||||||
enabled: parseBool(process.env.SMTP_ENABLED) || false,
|
enabled: parseBool(process.env.SMTP_ENABLED) || false,
|
||||||
host: parseValue(process.env.SMTP_HOST),
|
|
||||||
port: Number(process.env.SMTP_PORT) || 465,
|
|
||||||
secure: parseBool(process.env.SMTP_SECURE) || true,
|
|
||||||
user: parseValue(process.env.SMTP_USER),
|
user: parseValue(process.env.SMTP_USER),
|
||||||
password: parseValue(process.env.SMTP_PASSWORD)
|
password: parseValue(process.env.SMTP_PASSWORD),
|
||||||
|
server: parseValue(process.env.SMTP_SERVER),
|
||||||
|
port: Number(process.env.SMTP_PORT) || 465,
|
||||||
|
secure: parseBool(process.env.SMTP_SECURE) || true
|
||||||
},
|
},
|
||||||
|
|
||||||
http: {
|
http: {
|
||||||
|
|
@ -70,7 +70,8 @@ const config = {
|
||||||
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
||||||
branding: parseValue(process.env.HTTP_BRANDING),
|
branding: parseValue(process.env.HTTP_BRANDING),
|
||||||
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
||||||
hideOther: parseBool(process.env.HTTP_HIDE_OTHER)
|
hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
|
||||||
|
statisticsEnabled: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false
|
||||||
},
|
},
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,25 @@ class Helper {
|
||||||
return footer
|
return footer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a mail count html element with tooltip for the footer
|
||||||
|
* @param {number} count - Current mail count
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
mailCountBuilder(count) {
|
||||||
|
const imapService = require('./imap-service')
|
||||||
|
const largestUid = imapService.getLargestUid ? imapService.getLargestUid() : null
|
||||||
|
let tooltip = ''
|
||||||
|
|
||||||
|
if (largestUid && largestUid > 0) {
|
||||||
|
tooltip = `All-time total: ${largestUid} emails`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<label title="${tooltip}">
|
||||||
|
<h4 style="display: inline;"><u><i>${count} mails</i></u></h4>
|
||||||
|
</label>`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuffle an array using the Durstenfeld shuffle algorithm
|
* Shuffle an array using the Durstenfeld shuffle algorithm
|
||||||
* @param {Array} array
|
* @param {Array} array
|
||||||
|
|
@ -172,14 +191,8 @@ class Helper {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLargestUid(imapService) {
|
async getLargestUid(imapService) {
|
||||||
return await imapService.getLargestUid();
|
const uid = await imapService.getLargestUid();
|
||||||
}
|
return uid || 0;
|
||||||
|
|
||||||
countElementBuilder(count = 0, largestUid = 0) {
|
|
||||||
const handling = `<label title="Historically managed ${largestUid} email${largestUid === 1 ? '' : 's'}">
|
|
||||||
<h4 style="display: inline;"><u><i>${count}</i></u> mail${count === 1 ? '' : 's'}</h4>
|
|
||||||
</label>`
|
|
||||||
return handling
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -256,7 +269,7 @@ class Helper {
|
||||||
|
|
||||||
// Warn about old locked-inboxes.db
|
// Warn about old locked-inboxes.db
|
||||||
if (fs.existsSync(legacyLockedInboxesDb)) {
|
if (fs.existsSync(legacyLockedInboxesDb)) {
|
||||||
console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`)
|
console.log(`WARNING: Found legacy ${legacyLockedInboxesDb}`)
|
||||||
console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`)
|
console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`)
|
||||||
console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`)
|
console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`)
|
||||||
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')
|
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,17 @@ class ImapService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectAndLoadMessages() {
|
async connectAndLoadMessages() {
|
||||||
const configWithListener = {
|
// Map config.imap.secure to config.imap.tls for imap-simple library compatibility
|
||||||
...this.config,
|
const imapConfig = {
|
||||||
|
imap: {
|
||||||
|
user: this.config.imap.user,
|
||||||
|
password: this.config.imap.password,
|
||||||
|
host: this.config.imap.host,
|
||||||
|
port: this.config.imap.port,
|
||||||
|
tls: this.config.imap.secure,
|
||||||
|
authTimeout: this.config.imap.authTimeout,
|
||||||
|
tlsOptions: { rejectUnauthorized: false }
|
||||||
|
},
|
||||||
// 'onmail' adds a callback when new mails arrive. With this we can keep the imap refresh interval very low (or even disable it).
|
// 'onmail' adds a callback when new mails arrive. With this we can keep the imap refresh interval very low (or even disable it).
|
||||||
onmail: () => this._doOnNewMail()
|
onmail: () => this._doOnNewMail()
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +120,7 @@ class ImapService extends EventEmitter {
|
||||||
this._doAfterInitialLoad()
|
this._doAfterInitialLoad()
|
||||||
)
|
)
|
||||||
|
|
||||||
await this._connectWithRetry(configWithListener)
|
await this._connectWithRetry(imapConfig)
|
||||||
|
|
||||||
// Load all messages in the background. (ASYNC)
|
// Load all messages in the background. (ASYNC)
|
||||||
this._loadMailSummariesAndEmitAsEvents()
|
this._loadMailSummariesAndEmitAsEvents()
|
||||||
|
|
@ -242,13 +251,9 @@ class ImapService extends EventEmitter {
|
||||||
async deleteOldMails(deleteMailsBefore) {
|
async deleteOldMails(deleteMailsBefore) {
|
||||||
let uids;
|
let uids;
|
||||||
|
|
||||||
// Only do heavy IMAP date filtering if the cutoff is older than 1 day
|
// IMAP date filters are unreliable - some servers search internal date, not Date header
|
||||||
const useDateFilter = helper.moreThanOneDay(new Date(), deleteMailsBefore);
|
// Always fetch all UIDs and filter by date header in JavaScript instead
|
||||||
|
const searchQuery = [
|
||||||
const searchQuery = useDateFilter ? [
|
|
||||||
['!DELETED'],
|
|
||||||
['BEFORE', deleteMailsBefore]
|
|
||||||
] : [
|
|
||||||
['!DELETED']
|
['!DELETED']
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const helper = new(Helper)
|
||||||
|
|
||||||
|
|
||||||
class MailProcessingService extends EventEmitter {
|
class MailProcessingService extends EventEmitter {
|
||||||
constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null) {
|
constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null, statisticsStore = null) {
|
||||||
super()
|
super()
|
||||||
this.mailRepository = mailRepository
|
this.mailRepository = mailRepository
|
||||||
this.clientNotification = clientNotification
|
this.clientNotification = clientNotification
|
||||||
|
|
@ -15,6 +15,7 @@ class MailProcessingService extends EventEmitter {
|
||||||
this.config = config
|
this.config = config
|
||||||
this.smtpService = smtpService
|
this.smtpService = smtpService
|
||||||
this.verificationStore = verificationStore
|
this.verificationStore = verificationStore
|
||||||
|
this.statisticsStore = statisticsStore
|
||||||
this.helper = new(Helper)
|
this.helper = new(Helper)
|
||||||
|
|
||||||
// Cached methods:
|
// Cached methods:
|
||||||
|
|
@ -164,6 +165,11 @@ class MailProcessingService extends EventEmitter {
|
||||||
if (this.initialLoadDone) {
|
if (this.initialLoadDone) {
|
||||||
// For now, only log messages if they arrive after the initial load
|
// For now, only log messages if they arrive after the initial load
|
||||||
debug('New mail for', mail.to[0])
|
debug('New mail for', mail.to[0])
|
||||||
|
|
||||||
|
// Track email received
|
||||||
|
if (this.statisticsStore) {
|
||||||
|
this.statisticsStore.recordReceive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mail.to.forEach(to => {
|
mail.to.forEach(to => {
|
||||||
|
|
@ -179,6 +185,11 @@ class MailProcessingService extends EventEmitter {
|
||||||
onMailDeleted(uid) {
|
onMailDeleted(uid) {
|
||||||
debug('Mail deleted:', uid)
|
debug('Mail deleted:', uid)
|
||||||
|
|
||||||
|
// Track email deleted
|
||||||
|
if (this.statisticsStore) {
|
||||||
|
this.statisticsStore.recordDelete()
|
||||||
|
}
|
||||||
|
|
||||||
// Clear cache for this specific UID
|
// Clear cache for this specific UID
|
||||||
try {
|
try {
|
||||||
this._clearCacheForUid(uid)
|
this._clearCacheForUid(uid)
|
||||||
|
|
@ -266,6 +277,11 @@ class MailProcessingService extends EventEmitter {
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
debug(`Email forwarded successfully. MessageId: ${result.messageId}`)
|
debug(`Email forwarded successfully. MessageId: ${result.messageId}`)
|
||||||
|
|
||||||
|
// Track email forwarded
|
||||||
|
if (this.statisticsStore) {
|
||||||
|
this.statisticsStore.recordForward()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debug(`Email forwarding failed: ${result.error}`)
|
debug(`Email forwarding failed: ${result.error}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class SmtpService {
|
||||||
_isConfigured() {
|
_isConfigured() {
|
||||||
return !!(
|
return !!(
|
||||||
this.config.smtp.enabled &&
|
this.config.smtp.enabled &&
|
||||||
this.config.smtp.host &&
|
this.config.smtp.server &&
|
||||||
this.config.smtp.user &&
|
this.config.smtp.user &&
|
||||||
this.config.smtp.password
|
this.config.smtp.password
|
||||||
)
|
)
|
||||||
|
|
@ -38,7 +38,7 @@ class SmtpService {
|
||||||
_initializeTransporter() {
|
_initializeTransporter() {
|
||||||
try {
|
try {
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
host: this.config.smtp.host,
|
host: this.config.smtp.server,
|
||||||
port: this.config.smtp.port,
|
port: this.config.smtp.port,
|
||||||
secure: this.config.smtp.secure,
|
secure: this.config.smtp.secure,
|
||||||
auth: {
|
auth: {
|
||||||
|
|
@ -52,7 +52,7 @@ class SmtpService {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
debug(`SMTP transporter initialized: ${this.config.smtp.host}:${this.config.smtp.port}`)
|
debug(`SMTP transporter initialized: ${this.config.smtp.server}:${this.config.smtp.port}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug('Failed to initialize SMTP transporter:', error.message)
|
debug('Failed to initialize SMTP transporter:', error.message)
|
||||||
throw new Error(`SMTP initialization failed: ${error.message}`)
|
throw new Error(`SMTP initialization failed: ${error.message}`)
|
||||||
|
|
@ -273,7 +273,7 @@ ${mail.html}
|
||||||
<p><code>${verificationLink}</code></p>
|
<p><code>${verificationLink}</code></p>
|
||||||
|
|
||||||
<div class="warning">
|
<div class="warning">
|
||||||
⚠️ <strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
|
<strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
||||||
|
|
|
||||||
511
domain/statistics-store.js
Normal file
511
domain/statistics-store.js
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
const debug = require('debug')('48hr-email:stats-store')
|
||||||
|
const config = require('../application/config')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics Store - Tracks email metrics and historical data
|
||||||
|
* Stores 24-hour rolling statistics for receives, deletes, and forwards
|
||||||
|
* Persists data to database for survival across restarts
|
||||||
|
*/
|
||||||
|
class StatisticsStore {
|
||||||
|
constructor(db = null) {
|
||||||
|
this.db = db
|
||||||
|
|
||||||
|
// Current totals
|
||||||
|
this.currentCount = 0
|
||||||
|
this.largestUid = 0
|
||||||
|
|
||||||
|
// 24-hour rolling data (one entry per minute = 1440 entries)
|
||||||
|
this.hourlyData = []
|
||||||
|
this.maxDataPoints = 24 * 60 // 24 hours * 60 minutes
|
||||||
|
|
||||||
|
// Track last cleanup to avoid too frequent operations
|
||||||
|
this.lastCleanup = Date.now()
|
||||||
|
|
||||||
|
// Historical data caching to prevent repeated analysis
|
||||||
|
this.historicalData = null
|
||||||
|
this.lastAnalysisTime = 0
|
||||||
|
this.analysisCacheDuration = 5 * 60 * 1000 // Cache for 5 minutes
|
||||||
|
|
||||||
|
// Load persisted data if database is available
|
||||||
|
if (this.db) {
|
||||||
|
this._loadFromDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('Statistics store initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cutoff time based on email purge configuration
|
||||||
|
* @returns {number} Timestamp in milliseconds
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPurgeCutoffMs() {
|
||||||
|
const time = config.email.purgeTime.time
|
||||||
|
const unit = config.email.purgeTime.unit
|
||||||
|
|
||||||
|
let cutoffMs = 0
|
||||||
|
switch (unit) {
|
||||||
|
case 'minutes':
|
||||||
|
cutoffMs = time * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'hours':
|
||||||
|
cutoffMs = time * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'days':
|
||||||
|
cutoffMs = time * 24 * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
cutoffMs = 48 * 60 * 60 * 1000 // Fallback to 48 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
return cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load statistics from database
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_loadFromDatabase() {
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare('SELECT largest_uid, hourly_data, last_updated FROM statistics WHERE id = 1')
|
||||||
|
const row = stmt.get()
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
this.largestUid = row.largest_uid || 0
|
||||||
|
|
||||||
|
// Parse hourly data
|
||||||
|
if (row.hourly_data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.hourly_data)
|
||||||
|
// Filter out stale data based on config purge time
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
this.hourlyData = parsed.filter(entry => entry.timestamp >= cutoff)
|
||||||
|
debug(`Loaded ${this.hourlyData.length} hourly data points from database (cutoff: ${new Date(cutoff).toISOString()})`)
|
||||||
|
} catch (e) {
|
||||||
|
debug('Failed to parse hourly data:', e.message)
|
||||||
|
this.hourlyData = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Loaded from database: largestUid=${this.largestUid}, hourlyData=${this.hourlyData.length} entries`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug('Failed to load statistics from database:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save statistics to database
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_saveToDatabase() {
|
||||||
|
if (!this.db) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE statistics
|
||||||
|
SET largest_uid = ?, hourly_data = ?, last_updated = ?
|
||||||
|
WHERE id = 1
|
||||||
|
`)
|
||||||
|
stmt.run(this.largestUid, JSON.stringify(this.hourlyData), Date.now())
|
||||||
|
debug('Statistics saved to database')
|
||||||
|
} catch (error) {
|
||||||
|
debug('Failed to save statistics to database:', error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize with current email count
|
||||||
|
* @param {number} count - Current email count
|
||||||
|
*/
|
||||||
|
initialize(count) {
|
||||||
|
this.currentCount = count
|
||||||
|
debug(`Initialized with ${count} emails`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update largest UID (all-time total emails processed)
|
||||||
|
* @param {number} uid - Largest UID from mailbox (0 if no emails)
|
||||||
|
*/
|
||||||
|
updateLargestUid(uid) {
|
||||||
|
if (uid >= 0 && uid > this.largestUid) {
|
||||||
|
this.largestUid = uid
|
||||||
|
this._saveToDatabase()
|
||||||
|
debug(`Largest UID updated to ${uid}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an email received event
|
||||||
|
*/
|
||||||
|
recordReceive() {
|
||||||
|
this.currentCount++
|
||||||
|
this._addDataPoint('receive')
|
||||||
|
debug(`Email received. Current: ${this.currentCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an email deleted event
|
||||||
|
*/
|
||||||
|
recordDelete() {
|
||||||
|
this.currentCount = Math.max(0, this.currentCount - 1)
|
||||||
|
this._addDataPoint('delete')
|
||||||
|
debug(`Email deleted. Current: ${this.currentCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an email forwarded event
|
||||||
|
*/
|
||||||
|
recordForward() {
|
||||||
|
this._addDataPoint('forward')
|
||||||
|
debug(`Email forwarded`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current count (for bulk operations like purge)
|
||||||
|
* @param {number} count - New current count
|
||||||
|
*/
|
||||||
|
updateCurrentCount(count) {
|
||||||
|
const diff = count - this.currentCount
|
||||||
|
if (diff < 0) {
|
||||||
|
// Bulk delete occurred
|
||||||
|
for (let i = 0; i < Math.abs(diff); i++) {
|
||||||
|
this._addDataPoint('delete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentCount = count
|
||||||
|
debug(`Current count updated to ${count}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current statistics
|
||||||
|
* @returns {Object} Current stats
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
const last24h = this._getLast24Hours()
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCount: this.currentCount,
|
||||||
|
allTimeTotal: this.largestUid,
|
||||||
|
last24Hours: {
|
||||||
|
receives: last24h.receives,
|
||||||
|
deletes: last24h.deletes,
|
||||||
|
forwards: last24h.forwards,
|
||||||
|
timeline: this._getTimeline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze all existing emails to build historical statistics
|
||||||
|
* @param {Array} allMails - Array of all mail summaries with date property
|
||||||
|
*/
|
||||||
|
analyzeHistoricalData(allMails) {
|
||||||
|
if (!allMails || allMails.length === 0) {
|
||||||
|
debug('No historical data to analyze')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache - if analysis was done recently, skip it
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.historicalData && (now - this.lastAnalysisTime) < this.analysisCacheDuration) {
|
||||||
|
debug(`Using cached historical data (${this.historicalData.length} points, age: ${Math.round((now - this.lastAnalysisTime) / 1000)}s)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Analyzing ${allMails.length} emails for historical statistics`)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Group emails by minute
|
||||||
|
const histogram = new Map()
|
||||||
|
|
||||||
|
allMails.forEach(mail => {
|
||||||
|
try {
|
||||||
|
const date = new Date(mail.date)
|
||||||
|
if (isNaN(date.getTime())) return
|
||||||
|
|
||||||
|
const minute = Math.floor(date.getTime() / 60000) * 60000
|
||||||
|
|
||||||
|
if (!histogram.has(minute)) {
|
||||||
|
histogram.set(minute, 0)
|
||||||
|
}
|
||||||
|
histogram.set(minute, histogram.get(minute) + 1)
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid dates
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to array and sort by timestamp
|
||||||
|
this.historicalData = Array.from(histogram.entries())
|
||||||
|
.map(([timestamp, count]) => ({ timestamp, receives: count }))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
|
this.lastAnalysisTime = now
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
debug(`Built historical data: ${this.historicalData.length} time buckets in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enhanced statistics with historical data and predictions
|
||||||
|
* @returns {Object} Enhanced stats with historical timeline and predictions
|
||||||
|
*/
|
||||||
|
getEnhancedStats() {
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
const last24h = this._getLast24Hours()
|
||||||
|
const timeline = this._getTimeline()
|
||||||
|
const historicalTimeline = this._getHistoricalTimeline()
|
||||||
|
const prediction = this._generatePrediction()
|
||||||
|
|
||||||
|
// Calculate historical receives from purge time window
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
const historicalReceives = historicalTimeline
|
||||||
|
.filter(point => point.timestamp >= cutoff)
|
||||||
|
.reduce((sum, point) => sum + point.receives, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCount: this.currentCount,
|
||||||
|
allTimeTotal: this.largestUid,
|
||||||
|
last24Hours: {
|
||||||
|
receives: last24h.receives + historicalReceives,
|
||||||
|
deletes: last24h.deletes,
|
||||||
|
forwards: last24h.forwards,
|
||||||
|
timeline: timeline
|
||||||
|
},
|
||||||
|
historical: historicalTimeline,
|
||||||
|
prediction: prediction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lightweight statistics without historical analysis (for API updates)
|
||||||
|
* @returns {Object} Stats with only realtime data
|
||||||
|
*/
|
||||||
|
getLightweightStats() {
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
const last24h = this._getLast24Hours()
|
||||||
|
const timeline = this._getTimeline()
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCount: this.currentCount,
|
||||||
|
allTimeTotal: this.largestUid,
|
||||||
|
last24Hours: {
|
||||||
|
receives: last24h.receives,
|
||||||
|
deletes: last24h.deletes,
|
||||||
|
forwards: last24h.forwards,
|
||||||
|
timeline: timeline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical timeline for visualization
|
||||||
|
* Shows data for the configured purge duration, aggregated by hour
|
||||||
|
* @returns {Array} Historical data points
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getHistoricalTimeline() {
|
||||||
|
if (!this.historicalData || this.historicalData.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show historical data up to the purge time window
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
const relevantHistory = this.historicalData.filter(point => point.timestamp >= cutoff)
|
||||||
|
|
||||||
|
// Aggregate by hour
|
||||||
|
const hourlyBuckets = new Map()
|
||||||
|
relevantHistory.forEach(point => {
|
||||||
|
const hour = Math.floor(point.timestamp / 3600000) * 3600000
|
||||||
|
if (!hourlyBuckets.has(hour)) {
|
||||||
|
hourlyBuckets.set(hour, 0)
|
||||||
|
}
|
||||||
|
hourlyBuckets.set(hour, hourlyBuckets.get(hour) + point.receives)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to array and sort
|
||||||
|
const hourlyData = Array.from(hourlyBuckets.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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate prediction for next period based on historical patterns
|
||||||
|
* Uses config purge time to determine prediction window
|
||||||
|
* Predicts based on time-of-day patterns with randomization
|
||||||
|
* @returns {Array} Predicted data points
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_generatePrediction() {
|
||||||
|
if (!this.historicalData || this.historicalData.length < 100) {
|
||||||
|
return [] // Not enough data to predict
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const predictions = []
|
||||||
|
|
||||||
|
// Build hourly patterns from historical data
|
||||||
|
// Map hour-of-day to average receives count
|
||||||
|
const hourlyPatterns = new Map()
|
||||||
|
|
||||||
|
this.historicalData.forEach(point => {
|
||||||
|
const date = new Date(point.timestamp)
|
||||||
|
const hour = date.getHours()
|
||||||
|
|
||||||
|
if (!hourlyPatterns.has(hour)) {
|
||||||
|
hourlyPatterns.set(hour, [])
|
||||||
|
}
|
||||||
|
hourlyPatterns.get(hour).push(point.receives)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate average for each hour
|
||||||
|
const hourlyAverages = new Map()
|
||||||
|
hourlyPatterns.forEach((values, hour) => {
|
||||||
|
const avg = values.reduce((sum, v) => sum + v, 0) / values.length
|
||||||
|
hourlyAverages.set(hour, avg)
|
||||||
|
})
|
||||||
|
|
||||||
|
debug(`Built hourly patterns for ${hourlyAverages.size} hours from ${this.historicalData.length} data points`)
|
||||||
|
|
||||||
|
// Generate predictions for purge duration (in 1-hour intervals)
|
||||||
|
const purgeMs = this._getPurgeCutoffMs()
|
||||||
|
const predictionHours = Math.ceil(purgeMs / (60 * 60 * 1000))
|
||||||
|
|
||||||
|
for (let i = 1; i <= predictionHours; i++) {
|
||||||
|
const timestamp = now + (i * 60 * 60 * 1000) // 1 hour intervals
|
||||||
|
const futureDate = new Date(timestamp)
|
||||||
|
const futureHour = futureDate.getHours()
|
||||||
|
|
||||||
|
// Get average for this hour, or fallback to overall average
|
||||||
|
let baseCount = hourlyAverages.get(futureHour)
|
||||||
|
if (baseCount === undefined) {
|
||||||
|
// Fallback to overall average if no data for this hour
|
||||||
|
const allValues = Array.from(hourlyAverages.values())
|
||||||
|
baseCount = allValues.reduce((sum, v) => sum + v, 0) / allValues.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseCount is already per-minute average, scale to full hour
|
||||||
|
const scaledCount = baseCount * 60
|
||||||
|
|
||||||
|
// Add randomization (±20%)
|
||||||
|
const randomFactor = 0.8 + (Math.random() * 0.4) // 0.8 to 1.2
|
||||||
|
const predictedCount = Math.round(scaledCount * randomFactor)
|
||||||
|
|
||||||
|
predictions.push({
|
||||||
|
timestamp,
|
||||||
|
receives: Math.max(0, predictedCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(`Generated ${predictions.length} prediction points based on hourly patterns`)
|
||||||
|
return predictions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a data point to the rolling history
|
||||||
|
* @param {string} type - Type of event (receive, delete, forward)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_addDataPoint(type) {
|
||||||
|
const now = Date.now()
|
||||||
|
const minute = Math.floor(now / 60000) * 60000 // Round to minute
|
||||||
|
|
||||||
|
// Find or create entry for this minute
|
||||||
|
let entry = this.hourlyData.find(e => e.timestamp === minute)
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
timestamp: minute,
|
||||||
|
receives: 0,
|
||||||
|
deletes: 0,
|
||||||
|
forwards: 0
|
||||||
|
}
|
||||||
|
this.hourlyData.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry[type + 's']++
|
||||||
|
|
||||||
|
this._cleanup()
|
||||||
|
|
||||||
|
// Save to database periodically (every 10 data points to reduce I/O)
|
||||||
|
if (Math.random() < 0.1) { // ~10% chance = every ~10 events
|
||||||
|
this._saveToDatabase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old data points (older than email purge time)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_cleanup() {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Only cleanup every 5 minutes to avoid constant filtering
|
||||||
|
if (now - this.lastCleanup < 5 * 60 * 1000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = now - this._getPurgeCutoffMs()
|
||||||
|
const beforeCount = this.hourlyData.length
|
||||||
|
this.hourlyData = this.hourlyData.filter(entry => entry.timestamp >= cutoff)
|
||||||
|
|
||||||
|
if (beforeCount !== this.hourlyData.length) {
|
||||||
|
this._saveToDatabase() // Save after cleanup
|
||||||
|
debug(`Cleaned up ${beforeCount - this.hourlyData.length} old data points (keeping data for ${config.email.purgeTime.time} ${config.email.purgeTime.unit})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated stats for the purge time window
|
||||||
|
* @returns {Object} Aggregated counts
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getLast24Hours() {
|
||||||
|
const cutoff = Date.now() - this._getPurgeCutoffMs()
|
||||||
|
const recent = this.hourlyData.filter(e => e.timestamp >= cutoff)
|
||||||
|
|
||||||
|
return {
|
||||||
|
receives: recent.reduce((sum, e) => sum + e.receives, 0),
|
||||||
|
deletes: recent.reduce((sum, e) => sum + e.deletes, 0),
|
||||||
|
forwards: recent.reduce((sum, e) => sum + e.forwards, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timeline data for graphing (hourly aggregates)
|
||||||
|
* Uses purge time for consistent timeline length
|
||||||
|
* @returns {Array} Array of hourly data points
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getTimeline() {
|
||||||
|
const now = Date.now()
|
||||||
|
const cutoff = now - this._getPurgeCutoffMs()
|
||||||
|
const hourly = {}
|
||||||
|
|
||||||
|
// Aggregate by hour
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
hourly[hour].receives += entry.receives
|
||||||
|
hourly[hour].deletes += entry.deletes
|
||||||
|
hourly[hour].forwards += entry.forwards
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to sorted array
|
||||||
|
return Object.values(hourly).sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = StatisticsStore
|
||||||
|
|
@ -354,6 +354,100 @@ class UserRepository {
|
||||||
return `${Math.floor(days / 365)} years`
|
return `${Math.floor(days / 365)} years`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user password
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} password - Plain text password to verify
|
||||||
|
* @returns {Promise<boolean>} - True if password matches
|
||||||
|
*/
|
||||||
|
async verifyPassword(userId, password) {
|
||||||
|
try {
|
||||||
|
const bcrypt = require('bcrypt')
|
||||||
|
const stmt = this.db.prepare('SELECT password_hash FROM users WHERE id = ?')
|
||||||
|
const user = stmt.get(userId)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
debug(`User not found for password verification: ${userId}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(password, user.password_hash)
|
||||||
|
debug(`Password verification for user ${userId}: ${isValid ? 'success' : 'failed'}`)
|
||||||
|
return isValid
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Error verifying password: ${error.message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user password
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} newPassword - New plain text password
|
||||||
|
* @returns {Promise<boolean>} - True if successful
|
||||||
|
*/
|
||||||
|
async updatePassword(userId, newPassword) {
|
||||||
|
try {
|
||||||
|
const bcrypt = require('bcrypt')
|
||||||
|
const saltRounds = 10
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, saltRounds)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`)
|
||||||
|
const result = stmt.run(passwordHash, userId)
|
||||||
|
|
||||||
|
if (result.changes > 0) {
|
||||||
|
debug(`Password updated for user ${userId}`)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
debug(`User not found for password update: ${userId}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Error updating password: ${error.message}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user account and all associated data
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {boolean} - True if successful
|
||||||
|
*/
|
||||||
|
deleteUser(userId) {
|
||||||
|
try {
|
||||||
|
// Delete in order due to foreign key constraints:
|
||||||
|
// 1. forward_emails (references users.id)
|
||||||
|
// 2. users
|
||||||
|
|
||||||
|
const deleteForwardEmails = this.db.prepare('DELETE FROM forward_emails WHERE user_id = ?')
|
||||||
|
const deleteUser = this.db.prepare('DELETE FROM users WHERE id = ?')
|
||||||
|
|
||||||
|
// Use transaction for atomicity
|
||||||
|
const deleteTransaction = this.db.transaction((uid) => {
|
||||||
|
deleteForwardEmails.run(uid)
|
||||||
|
const result = deleteUser.run(uid)
|
||||||
|
return result.changes > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const success = deleteTransaction(userId)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
debug(`User ${userId} and all associated data deleted`)
|
||||||
|
} else {
|
||||||
|
debug(`User ${userId} not found for deletion`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Error deleting user: ${error.message}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close database connection
|
* Close database connection
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,13 @@ class ClientNotification extends EventEmitter {
|
||||||
this.pendingNotifications.set(address, prev + 1);
|
this.pendingNotifications.set(address, prev + 1);
|
||||||
debug(`No listeners for ${address}, queued notification (${prev + 1} pending)`);
|
debug(`No listeners for ${address}, queued notification (${prev + 1} pending)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also emit a global stats-update event for stats page
|
||||||
|
if (this.io) {
|
||||||
|
this.io.emit('stats-update');
|
||||||
|
debug('Emitted stats-update to all connected clients');
|
||||||
|
}
|
||||||
|
|
||||||
return hadListeners;
|
return hadListeners;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,12 @@ function checkLockAccess(req, res, next) {
|
||||||
|
|
||||||
// Block access to locked inbox without proper authentication
|
// Block access to locked inbox without proper authentication
|
||||||
if (isLocked && !hasAccess) {
|
if (isLocked && !hasAccess) {
|
||||||
const count = req.app.get('mailProcessingService').getCount()
|
|
||||||
const unlockError = req.session ? req.session.unlockError : undefined
|
const unlockError = req.session ? req.session.unlockError : undefined
|
||||||
if (req.session) delete req.session.unlockError
|
if (req.session) delete req.session.unlockError
|
||||||
|
|
||||||
return res.render('error', {
|
return res.render('error', {
|
||||||
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
|
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
|
||||||
address: address,
|
address: address,
|
||||||
count: count,
|
|
||||||
message: 'This inbox is locked by another user. Only the owner can access it.',
|
message: 'This inbox is locked by another user. Only the owner can access it.',
|
||||||
branding: req.app.get('config').http.branding,
|
branding: req.app.get('config').http.branding,
|
||||||
currentUser: req.session && req.session.username,
|
currentUser: req.session && req.session.username,
|
||||||
|
|
|
||||||
237
infrastructure/web/public/javascripts/stats.js
Normal file
237
infrastructure/web/public/javascripts/stats.js
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
/**
|
||||||
|
* Statistics page functionality
|
||||||
|
* Handles Chart.js initialization with historical, real-time, and predicted data
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize stats chart if on stats page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const chartCanvas = document.getElementById('statsChart');
|
||||||
|
if (!chartCanvas) return; // Not on stats page
|
||||||
|
|
||||||
|
// Get data from global variables (set by template)
|
||||||
|
if (typeof window.initialStatsData === 'undefined') {
|
||||||
|
console.error('Initial stats data not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realtimeData = window.initialStatsData || [];
|
||||||
|
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
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' })),
|
||||||
|
...realtimeData.map(d => ({...d, type: 'realtime' })),
|
||||||
|
...predictionData.map(d => ({...d, type: 'prediction' }))
|
||||||
|
].sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare datasets
|
||||||
|
const historicalPoints = allTimePoints.map(d => d.type === 'historical' ? d.receives : null);
|
||||||
|
const realtimePoints = allTimePoints.map(d => 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)');
|
||||||
|
|
||||||
|
// Track visibility state for each dataset
|
||||||
|
const datasetVisibility = [true, true, true];
|
||||||
|
|
||||||
|
const chart = 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: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Current Activity',
|
||||||
|
data: realtimePoints,
|
||||||
|
borderColor: '#2ecc71',
|
||||||
|
backgroundColor: 'rgba(46, 204, 113, 0.15)',
|
||||||
|
borderWidth: 4,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#2ecc71',
|
||||||
|
spanGaps: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Predicted',
|
||||||
|
data: predictionPoints,
|
||||||
|
borderColor: '#ff9f43',
|
||||||
|
backgroundColor: 'rgba(255, 159, 67, 0.08)',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderDash: [8, 4],
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#ff9f43',
|
||||||
|
spanGaps: false,
|
||||||
|
fill: true,
|
||||||
|
hidden: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false // Disable default legend, we'll create custom
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title: function(context) {
|
||||||
|
const dataIndex = context[0].dataIndex;
|
||||||
|
const point = allTimePoints[dataIndex];
|
||||||
|
const date = new Date(point.timestamp);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y + ' emails';
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||||
|
stepSize: 1,
|
||||||
|
callback: function(value) {
|
||||||
|
return Math.round(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.1)'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Emails Received',
|
||||||
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'),
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 45,
|
||||||
|
maxTicksLimit: 20
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create custom legend buttons
|
||||||
|
const chartContainer = chartCanvas.parentElement;
|
||||||
|
const legendContainer = document.createElement('div');
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chartContainer.insertBefore(legendContainer, chartCanvas);
|
||||||
|
|
||||||
|
// Handle legend button clicks
|
||||||
|
legendContainer.querySelectorAll('.legend-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const index = parseInt(this.getAttribute('data-index'));
|
||||||
|
const isActive = this.classList.contains('active');
|
||||||
|
|
||||||
|
// Toggle button state
|
||||||
|
this.classList.toggle('active');
|
||||||
|
|
||||||
|
// Toggle dataset visibility with fade effect
|
||||||
|
const meta = chart.getDatasetMeta(index);
|
||||||
|
const dataset = chart.data.datasets[index];
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Fade out
|
||||||
|
meta.hidden = true;
|
||||||
|
datasetVisibility[index] = false;
|
||||||
|
} else {
|
||||||
|
// Fade in
|
||||||
|
meta.hidden = false;
|
||||||
|
datasetVisibility[index] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -163,6 +163,18 @@ a:hover {
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.page-title {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-purple-light), var(--color-accent-purple-alt));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
|
@ -416,7 +428,8 @@ text-muted {
|
||||||
/* Auth pages */
|
/* Auth pages */
|
||||||
|
|
||||||
.auth-container {
|
.auth-container {
|
||||||
max-width: 900px;
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -456,9 +469,10 @@ text-muted {
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card small {
|
.auth-card small {
|
||||||
|
margin-top: -10px !important;
|
||||||
|
font-size: 1.2rem;
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--color-text-gray);
|
color: var(--color-text-gray);
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -549,7 +563,8 @@ text-muted {
|
||||||
/* Unified auth page (side-by-side login/register) */
|
/* Unified auth page (side-by-side login/register) */
|
||||||
|
|
||||||
.auth-unified-container {
|
.auth-unified-container {
|
||||||
max-width: 1100px;
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -569,6 +584,7 @@ text-muted {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 3rem;
|
gap: 3rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
@ -578,6 +594,27 @@ text-muted {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card .button {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card h2 {
|
.auth-card h2 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
@ -729,7 +766,8 @@ text-muted {
|
||||||
/* Account dashboard page */
|
/* Account dashboard page */
|
||||||
|
|
||||||
.account-container {
|
.account-container {
|
||||||
max-width: 1200px;
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -751,12 +789,14 @@ text-muted {
|
||||||
border: 1px solid var(--color-border-dark);
|
border: 1px solid var(--color-border-dark);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-card h2 {
|
.account-card h2 {
|
||||||
color: var(--color-accent-purple);
|
color: var(--color-accent-purple);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 1.5rem;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-description {
|
.card-description {
|
||||||
|
|
@ -765,7 +805,7 @@ text-muted {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-stats {
|
.account-card:nth-child(1) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -898,6 +938,87 @@ form {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form small {
|
||||||
|
color: var(--color-text-gray);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-form .button {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
border: 2px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone h2 {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-content {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1rem 0 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-list li::before {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-content .button {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.account-grid {
|
.account-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -978,6 +1099,390 @@ select:hover {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Frosted Glass Utility Class */
|
||||||
|
|
||||||
|
.frosted-glass {
|
||||||
|
background: linear-gradient(135deg, var(--overlay-white-08), var(--overlay-white-04));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--overlay-white-15);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 8px 32px var(--overlay-black-40), inset 0 1px 0 var(--overlay-white-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-mode .frosted-glass {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.5));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Homepage Styles */
|
||||||
|
|
||||||
|
.homepage-container {
|
||||||
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
padding: 4rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 4.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-creator {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto 6rem;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"] {
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
border: 1px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--overlay-white-04);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent-purple-light);
|
||||||
|
background: var(--overlay-white-06);
|
||||||
|
box-shadow: 0 0 0 3px var(--overlay-purple-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper::after {
|
||||||
|
content: '▾';
|
||||||
|
position: absolute;
|
||||||
|
right: 1.6rem;
|
||||||
|
top: 35%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
padding-left: 1.6rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
border: 1px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--overlay-white-04);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select:hover {
|
||||||
|
background: var(--overlay-white-06);
|
||||||
|
border-color: var(--overlay-purple-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent-purple-light);
|
||||||
|
box-shadow: 0 0 0 3px var(--overlay-purple-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: var(--color-bg-dark);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-count {
|
||||||
|
margin-top: -10px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-text-dimmer);
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1.4rem 2.4rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
height: 5.2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn svg {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-purple), var(--color-accent-purple-bright));
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 16px var(--overlay-purple-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px var(--overlay-purple-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover svg {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--overlay-white-08);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
border: 1px solid var(--overlay-purple-25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--overlay-white-12);
|
||||||
|
border-color: var(--overlay-purple-35);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover svg {
|
||||||
|
transform: rotate(15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
padding: 1.4rem 1.8rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(46, 204, 113, 0.1);
|
||||||
|
border: 1px solid #2ecc71;
|
||||||
|
color: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(176, 0, 0, 0.1);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--overlay-warning-10);
|
||||||
|
border: 1px solid var(--color-warning);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert span {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
padding: 3rem 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 12px 40px var(--overlay-black-45), inset 0 1px 0 var(--overlay-white-15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Info Section */
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
margin-top: 5rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content {
|
||||||
|
padding: 4rem 3rem;
|
||||||
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content h2 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content h3 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--color-accent-purple-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content p {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content ul li {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content ul li::before {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
color: var(--color-accent-purple);
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content ul li strong {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content .note {
|
||||||
|
background: var(--overlay-purple-10);
|
||||||
|
border-left: 4px solid var(--color-accent-purple);
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
margin-top: 3rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 3.2rem;
|
||||||
|
}
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.inbox-creator {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.info-content {
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
}
|
||||||
|
.info-content h2 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
}
|
||||||
|
.info-content h3 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Legacy Login Styles (kept for compatibility) */
|
||||||
|
|
||||||
#login {
|
#login {
|
||||||
padding-top: 15vh;
|
padding-top: 15vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1584,6 +2089,8 @@ label {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
box-shadow: 0 4px 6px var(--overlay-black-30);
|
box-shadow: 0 4px 6px var(--overlay-black-30);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h3 {
|
.modal-content h3 {
|
||||||
|
|
@ -1634,6 +2141,23 @@ label {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content fieldset {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .button,
|
||||||
|
.modal-content .modal-button {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|
@ -1954,6 +2478,164 @@ body.light-mode .theme-icon-light {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Statistics Page */
|
||||||
|
|
||||||
|
.stats-container {
|
||||||
|
min-width: 75%;
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subtitle {
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: -1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subtitle .purge-time-inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-subtitle .purge-time-inline label,
|
||||||
|
.stats-subtitle .purge-time-inline h4 {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--overlay-white-05);
|
||||||
|
border: 1px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
background: var(--overlay-white-08);
|
||||||
|
border-color: var(--overlay-purple-40);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-purple-light);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--overlay-white-05);
|
||||||
|
border: 1px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 2rem;
|
||||||
|
height: 500px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container canvas {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Custom legend for stats chart */
|
||||||
|
|
||||||
|
.chart-legend-custom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: var(--overlay-white-05);
|
||||||
|
border: 2px solid var(--overlay-purple-30);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:hover {
|
||||||
|
background: var(--overlay-white-10);
|
||||||
|
border-color: var(--color-accent-purple);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(155, 77, 202, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn.active {
|
||||||
|
background: var(--overlay-purple-20);
|
||||||
|
border-color: var(--color-accent-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:not(.active) {
|
||||||
|
opacity: 0.4;
|
||||||
|
background: var(--overlay-white-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-btn:not(.active):hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chart-legend-custom {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.legend-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
/* Responsive Styles */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
@ -1963,12 +2645,57 @@ body.light-mode .theme-icon-light {
|
||||||
.hamburger-menu {
|
.hamburger-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
/* Hide dropdowns on mobile, show links directly */
|
||||||
|
.action-dropdown {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.action-dropdown .dropdown-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.action-dropdown .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
position: static;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
min-width: auto;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Add section headers before dropdown menus on mobile */
|
||||||
|
.action-dropdown .dropdown-menu::before {
|
||||||
|
content: attr(data-section-title);
|
||||||
|
display: block;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--color-text-dim);
|
||||||
|
padding: 12px 0 8px 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--overlay-white-10);
|
||||||
|
}
|
||||||
|
.action-dropdown:first-of-type .dropdown-menu::before {
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.action-dropdown .dropdown-menu a {
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
.action-dropdown .dropdown-menu a:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent-purple-light);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
.action-links.mobile-open .theme-toggle {
|
.action-links.mobile-open .theme-toggle {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.action-links.mobile-hidden>a,
|
.action-links.mobile-hidden>a,
|
||||||
.action-links.mobile-hidden>button:not(.hamburger-menu) {
|
.action-links.mobile-hidden>button:not(.hamburger-menu),
|
||||||
|
.action-links.mobile-hidden>.action-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.action-links.mobile-open {
|
.action-links.mobile-open {
|
||||||
|
|
@ -1982,7 +2709,7 @@ body.light-mode .theme-icon-light {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-shadow: 0 10px 40px var(--overlay-black-40);
|
box-shadow: 0 10px 40px var(--overlay-black-40);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 200px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
.action-links.mobile-open>.hamburger-menu {
|
.action-links.mobile-open>.hamburger-menu {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -1991,7 +2718,24 @@ body.light-mode .theme-icon-light {
|
||||||
.action-links.mobile-open>button:not(.hamburger-menu) {
|
.action-links.mobile-open>button:not(.hamburger-menu) {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
padding: 10px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
height: auto;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
.action-links.mobile-open>a:hover,
|
||||||
|
.action-links.mobile-open>button:not(.hamburger-menu):hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent-purple-light);
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
.qr-icon-btn {
|
.qr-icon-btn {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ router.get('/account', requireAuth, async(req, res) => {
|
||||||
const config = req.app.get('config')
|
const config = req.app.get('config')
|
||||||
const stats = userRepository.getUserStats(req.session.userId, config.user)
|
const stats = userRepository.getUserStats(req.session.userId, config.user)
|
||||||
|
|
||||||
// Get mail count for footer
|
// Get purge time for footer
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const imapService = req.app.locals.imapService
|
|
||||||
const largestUid = await imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
const purgeTime = helper.purgeTimeElemetBuilder()
|
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||||
|
|
||||||
res.render('account', {
|
res.render('account', {
|
||||||
|
|
@ -41,7 +37,6 @@ router.get('/account', requireAuth, async(req, res) => {
|
||||||
stats,
|
stats,
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
totalcount: totalcount,
|
|
||||||
successMessage: req.session.accountSuccess,
|
successMessage: req.session.accountSuccess,
|
||||||
errorMessage: req.session.accountError
|
errorMessage: req.session.accountError
|
||||||
})
|
})
|
||||||
|
|
@ -98,8 +93,8 @@ router.post('/account/forward-email/add',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send verification email
|
// Send verification email
|
||||||
const baseUrl = config.http.baseUrl || 'http://localhost:3000'
|
const baseUrl = config.http.baseUrl
|
||||||
const branding = config.http.branding[0] || '48hr.email'
|
const branding = config.http.branding[0]
|
||||||
|
|
||||||
await smtpService.sendVerificationEmail(
|
await smtpService.sendVerificationEmail(
|
||||||
email,
|
email,
|
||||||
|
|
@ -225,4 +220,108 @@ router.post('/account/locked-inbox/release',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// POST /account/change-password - Change user password
|
||||||
|
router.post('/account/change-password',
|
||||||
|
requireAuth,
|
||||||
|
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||||
|
body('newPassword').isLength({ min: 8 }).withMessage('New password must be at least 8 characters'),
|
||||||
|
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
|
||||||
|
async(req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req)
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
req.session.accountError = errors.array()[0].msg
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword, confirmNewPassword } = req.body
|
||||||
|
|
||||||
|
// Check if new passwords match
|
||||||
|
if (newPassword !== confirmNewPassword) {
|
||||||
|
req.session.accountError = 'New passwords do not match'
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new password strength
|
||||||
|
const hasUpperCase = /[A-Z]/.test(newPassword)
|
||||||
|
const hasLowerCase = /[a-z]/.test(newPassword)
|
||||||
|
const hasNumber = /[0-9]/.test(newPassword)
|
||||||
|
|
||||||
|
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
|
||||||
|
req.session.accountError = 'Password must include uppercase, lowercase, and number'
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRepository = req.app.get('userRepository')
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await userRepository.verifyPassword(req.session.userId, currentPassword)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
req.session.accountError = 'Current password is incorrect'
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await userRepository.updatePassword(req.session.userId, newPassword)
|
||||||
|
|
||||||
|
req.session.accountSuccess = 'Password updated successfully'
|
||||||
|
res.redirect('/account')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error)
|
||||||
|
req.session.accountError = 'Failed to change password. Please try again.'
|
||||||
|
res.redirect('/account')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /account/delete - Permanently delete user account
|
||||||
|
router.post('/account/delete',
|
||||||
|
requireAuth,
|
||||||
|
body('password').notEmpty().withMessage('Password is required'),
|
||||||
|
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
|
||||||
|
async(req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req)
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
req.session.accountError = errors.array()[0].msg
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password } = req.body
|
||||||
|
const userRepository = req.app.get('userRepository')
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await userRepository.verifyPassword(req.session.userId, password)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
req.session.accountError = 'Incorrect password'
|
||||||
|
return res.redirect('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's locked inboxes to release them
|
||||||
|
const inboxLock = req.app.get('inboxLock')
|
||||||
|
if (inboxLock) {
|
||||||
|
const lockedInboxes = inboxLock.getUserLockedInboxes(req.session.userId)
|
||||||
|
for (const inbox of lockedInboxes) {
|
||||||
|
inboxLock.release(req.session.userId, inbox.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user account
|
||||||
|
await userRepository.deleteUser(req.session.userId)
|
||||||
|
|
||||||
|
// Destroy session
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Session destroy error:', err)
|
||||||
|
}
|
||||||
|
res.redirect('/?deleted=true')
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete account error:', error)
|
||||||
|
req.session.accountError = 'Failed to delete account. Please try again.'
|
||||||
|
res.redirect('/account')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ const router = new express.Router()
|
||||||
const { body, validationResult } = require('express-validator')
|
const { body, validationResult } = require('express-validator')
|
||||||
const debug = require('debug')('48hr-email:auth-routes')
|
const debug = require('debug')('48hr-email:auth-routes')
|
||||||
const { redirectIfAuthenticated } = require('../middleware/auth')
|
const { redirectIfAuthenticated } = require('../middleware/auth')
|
||||||
|
const config = require('../../../application/config')
|
||||||
|
const Helper = require('../../../application/helper')
|
||||||
|
const helper = new Helper()
|
||||||
|
|
||||||
|
const purgeTime = helper.purgeTimeElemetBuilder()
|
||||||
|
|
||||||
// Simple in-memory rate limiters for registration and login
|
// Simple in-memory rate limiters for registration and login
|
||||||
const registrationRateLimitStore = new Map()
|
const registrationRateLimitStore = new Map()
|
||||||
|
|
@ -87,6 +92,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
|
||||||
res.render('auth', {
|
res.render('auth', {
|
||||||
title: `Login or Register | ${config.http.branding[0]}`,
|
title: `Login or Register | ${config.http.branding[0]}`,
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
|
purgeTime: purgeTime,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
successMessage
|
successMessage
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ router.get('/:address/:errorCode', async(req, res, next) => {
|
||||||
throw new Error('Mail processing service not available')
|
throw new Error('Mail processing service not available')
|
||||||
}
|
}
|
||||||
debug(`Error page requested: ${req.params.errorCode} for ${req.params.address}`)
|
debug(`Error page requested: ${req.params.errorCode} for ${req.params.address}`)
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
const errorCode = parseInt(req.params.errorCode) || 404
|
const errorCode = parseInt(req.params.errorCode) || 404
|
||||||
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred'
|
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred'
|
||||||
|
|
||||||
|
|
@ -27,8 +24,6 @@ router.get('/:address/:errorCode', async(req, res, next) => {
|
||||||
title: `${config.http.branding[0]} | ${errorCode}`,
|
title: `${config.http.branding[0]} | ${errorCode}`,
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
address: req.params.address,
|
address: req.params.address,
|
||||||
count: count,
|
|
||||||
totalcount: totalcount,
|
|
||||||
message: message,
|
message: message,
|
||||||
status: errorCode,
|
status: errorCode,
|
||||||
branding: config.http.branding
|
branding: config.http.branding
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,6 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
|
||||||
}
|
}
|
||||||
debug(`Inbox request for ${req.params.address}`)
|
debug(`Inbox request for ${req.params.address}`)
|
||||||
const inboxLock = req.app.get('inboxLock')
|
const inboxLock = req.app.get('inboxLock')
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
debug(`Rendering inbox with ${count} total mails`)
|
|
||||||
|
|
||||||
// Check lock status
|
// Check lock status
|
||||||
const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
|
const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
|
||||||
|
|
@ -151,8 +147,6 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
|
||||||
title: `${config.http.branding[0]} | ` + req.params.address,
|
title: `${config.http.branding[0]} | ` + req.params.address,
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
address: req.params.address,
|
address: req.params.address,
|
||||||
count: count,
|
|
||||||
totalcount: totalcount,
|
|
||||||
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
|
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
authEnabled: config.user.authEnabled,
|
authEnabled: config.user.authEnabled,
|
||||||
|
|
@ -189,9 +183,6 @@ router.get(
|
||||||
try {
|
try {
|
||||||
const mailProcessingService = req.app.get('mailProcessingService')
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
debug(`Viewing email ${req.params.uid} for ${req.params.address}`)
|
debug(`Viewing email ${req.params.uid} for ${req.params.address}`)
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
const mail = await mailProcessingService.getOneFullMail(
|
const mail = await mailProcessingService.getOneFullMail(
|
||||||
req.params.address,
|
req.params.address,
|
||||||
req.params.uid
|
req.params.uid
|
||||||
|
|
@ -246,8 +237,6 @@ router.get(
|
||||||
title: mail.subject + " | " + req.params.address,
|
title: mail.subject + " | " + req.params.address,
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
address: req.params.address,
|
address: req.params.address,
|
||||||
count: count,
|
|
||||||
totalcount: totalcount,
|
|
||||||
mail,
|
mail,
|
||||||
cryptoAttachments: cryptoAttachments,
|
cryptoAttachments: cryptoAttachments,
|
||||||
uid: req.params.uid,
|
uid: req.params.uid,
|
||||||
|
|
@ -336,7 +325,6 @@ router.get(
|
||||||
const mailProcessingService = req.app.get('mailProcessingService')
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`)
|
debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`)
|
||||||
const uid = parseInt(req.params.uid, 10)
|
const uid = parseInt(req.params.uid, 10)
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
|
|
||||||
// Validate UID is a valid integer
|
// Validate UID is a valid integer
|
||||||
if (isNaN(uid) || uid <= 0) {
|
if (isNaN(uid) || uid <= 0) {
|
||||||
|
|
@ -397,9 +385,6 @@ router.get(
|
||||||
const mailProcessingService = req.app.get('mailProcessingService')
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`)
|
debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`)
|
||||||
const uid = parseInt(req.params.uid, 10)
|
const uid = parseInt(req.params.uid, 10)
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
|
|
||||||
// Validate UID is a valid integer
|
// Validate UID is a valid integer
|
||||||
if (isNaN(uid) || uid <= 0) {
|
if (isNaN(uid) || uid <= 0) {
|
||||||
|
|
@ -440,8 +425,7 @@ router.get(
|
||||||
res.render('raw', {
|
res.render('raw', {
|
||||||
title: req.params.uid + " | raw | " + req.params.address,
|
title: req.params.uid + " | raw | " + req.params.address,
|
||||||
mail: rawMail,
|
mail: rawMail,
|
||||||
decoded: decodedMail,
|
decoded: decodedMail
|
||||||
totalcount: totalcount
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
debug(`Raw email ${uid} not found for ${req.params.address}`)
|
debug(`Raw email ${uid} not found for ${req.params.address}`)
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,12 @@ router.get('/', async(req, res, next) => {
|
||||||
throw new Error('Mail processing service not available')
|
throw new Error('Mail processing service not available')
|
||||||
}
|
}
|
||||||
debug('Login page requested')
|
debug('Login page requested')
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
||||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
||||||
debug(`Rendering login page with ${count} total mails`)
|
|
||||||
res.render('login', {
|
res.render('login', {
|
||||||
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
||||||
username: randomWord(),
|
username: randomWord(),
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
|
purgeTimeRaw: config.email.purgeTime,
|
||||||
domains: helper.getDomains(),
|
domains: helper.getDomains(),
|
||||||
count: count,
|
|
||||||
totalcount: totalcount,
|
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
example: config.email.examples.account,
|
example: config.email.examples.account,
|
||||||
})
|
})
|
||||||
|
|
@ -59,16 +54,15 @@ router.post(
|
||||||
throw new Error('Mail processing service not available')
|
throw new Error('Mail processing service not available')
|
||||||
}
|
}
|
||||||
const errors = validationResult(req)
|
const errors = validationResult(req)
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
|
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
|
||||||
return res.render('login', {
|
return res.render('login', {
|
||||||
userInputError: true,
|
userInputError: true,
|
||||||
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
title: `${config.http.branding[0]} | Your temporary Inbox`,
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
|
purgeTimeRaw: config.email.purgeTime,
|
||||||
username: randomWord(),
|
username: randomWord(),
|
||||||
domains: helper.getDomains(),
|
domains: helper.getDomains(),
|
||||||
count: count,
|
|
||||||
branding: config.http.branding,
|
branding: config.http.branding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
infrastructure/web/routes/stats.js
Normal file
77
infrastructure/web/routes/stats.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
const express = require('express')
|
||||||
|
const router = new express.Router()
|
||||||
|
const debug = require('debug')('48hr-email:stats-routes')
|
||||||
|
|
||||||
|
// GET /stats - Statistics page
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statisticsStore = req.app.get('statisticsStore')
|
||||||
|
const imapService = req.app.get('imapService')
|
||||||
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze all existing emails for historical data
|
||||||
|
if (mailProcessingService) {
|
||||||
|
const allMails = mailProcessingService.getAllMailSummaries()
|
||||||
|
statisticsStore.analyzeHistoricalData(allMails)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
res.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Error fetching stats API: ${error.message}`)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch statistics' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|
@ -16,24 +16,32 @@
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="account" class="account-container">
|
<div id="account" class="account-container">
|
||||||
<h1>Account Dashboard</h1>
|
<h1 class="page-title">Account Dashboard</h1>
|
||||||
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
|
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
|
||||||
|
|
||||||
{% if successMessage %}
|
{% if successMessage %}
|
||||||
<div class="success-message">
|
<div class="alert alert-success">
|
||||||
{{ successMessage }}
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
<p>{{ successMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if errorMessage %}
|
{% if errorMessage %}
|
||||||
<div class="unlock-error">
|
<div class="alert alert-error">
|
||||||
{{ errorMessage }}
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
<p>{{ errorMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="account-grid">
|
<div class="account-grid">
|
||||||
<!-- Account Stats -->
|
<!-- Account Stats -->
|
||||||
<div class="account-card account-stats">
|
<div class="account-card frosted-glass">
|
||||||
<h2>Account Overview</h2>
|
<h2>Account Overview</h2>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
|
|
@ -52,7 +60,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forwarding Emails Section -->
|
<!-- Forwarding Emails Section -->
|
||||||
<div class="account-card">
|
<div class="account-card frosted-glass">
|
||||||
<h2>Forwarding Emails</h2>
|
<h2>Forwarding Emails</h2>
|
||||||
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
|
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
|
||||||
|
|
||||||
|
|
@ -85,7 +93,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Locked Inboxes Section -->
|
<!-- Locked Inboxes Section -->
|
||||||
<div class="account-card">
|
<div class="account-card frosted-glass">
|
||||||
<h2>Locked Inboxes</h2>
|
<h2>Locked Inboxes</h2>
|
||||||
<p class="card-description">Manage your locked inboxes. These are protected by your account and only accessible when logged in. Locks auto-release after 7 days without login.</p>
|
<p class="card-description">Manage your locked inboxes. These are protected by your account and only accessible when logged in. Locks auto-release after 7 days without login.</p>
|
||||||
|
|
||||||
|
|
@ -116,6 +124,105 @@
|
||||||
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
|
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Section -->
|
||||||
|
<div class="account-card frosted-glass">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<p class="card-description">Update your account password. You'll need to enter your current password to confirm.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/account/change-password" class="password-form">
|
||||||
|
<fieldset>
|
||||||
|
<label for="currentPassword">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="newPassword">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<small>Must include uppercase, lowercase, and number</small>
|
||||||
|
|
||||||
|
<label for="confirmNewPassword">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmNewPassword"
|
||||||
|
name="confirmNewPassword"
|
||||||
|
placeholder="Re-enter new password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button type="submit" class="button button-primary">Update Password</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Account Section -->
|
||||||
|
<div class="account-card frosted-glass danger-zone">
|
||||||
|
<h2>Danger Zone</h2>
|
||||||
|
<p class="card-description">Permanently delete your account and all associated data. This action cannot be undone.</p>
|
||||||
|
|
||||||
|
<div class="danger-content">
|
||||||
|
<p><strong>Warning:</strong> Deleting your account will:</p>
|
||||||
|
<ul class="danger-list">
|
||||||
|
<li>Remove all forwarding email addresses</li>
|
||||||
|
<li>Release all locked inboxes</li>
|
||||||
|
<li>Permanently delete your account data</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="button button-danger button-full-width" id="deleteAccountBtn">Delete Account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Account Modal -->
|
||||||
|
<div id="deleteAccountModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" id="closeDeleteAccount">×</span>
|
||||||
|
<h3>Delete Account</h3>
|
||||||
|
<p class="modal-description" style="color: var(--color-danger);">This action is permanent and cannot be undone!</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/account/delete">
|
||||||
|
<fieldset>
|
||||||
|
<label for="confirmPassword">Enter your password to confirm</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="password"
|
||||||
|
placeholder="Your password"
|
||||||
|
required
|
||||||
|
class="modal-input"
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="confirmText">Type "DELETE" to confirm</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="confirmText"
|
||||||
|
name="confirmText"
|
||||||
|
placeholder="Type DELETE"
|
||||||
|
required
|
||||||
|
class="modal-input"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button type="submit" class="button button-danger modal-button">Permanently Delete Account</button>
|
||||||
|
<button type="button" class="button button-secondary modal-button" id="cancelDelete">Cancel</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,10 +268,37 @@ if (closeAddEmail) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
window.onclick = function(event) {
|
||||||
if (event.target == addEmailModal) {
|
if (event.target == addEmailModal) {
|
||||||
addEmailModal.style.display = 'none';
|
addEmailModal.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
if (event.target == deleteAccountModal) {
|
||||||
|
deleteAccountModal.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -17,53 +17,32 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="auth-unified" class="auth-unified-container">
|
<div id="auth-unified" class="auth-unified-container">
|
||||||
<div class="auth-intro">
|
<div class="auth-intro">
|
||||||
<h1>Account Access</h1>
|
<h1 class="page-title">Account Access</h1>
|
||||||
<p class="auth-subtitle">Login to an existing account or create a new one</p>
|
<p class="auth-subtitle">Login to an existing account or create a new one</p>
|
||||||
{% if errorMessage %}
|
{% if errorMessage %}
|
||||||
<div class="unlock-error">{{ errorMessage }}</div>
|
<div class="alert alert-error">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
|
</svg>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if successMessage %}
|
{% if successMessage %}
|
||||||
<div class="success-message">{{ successMessage }}</div>
|
<div class="alert alert-success">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-forms-grid">
|
<div class="auth-forms-grid">
|
||||||
<!-- Login Form -->
|
|
||||||
<div class="auth-card">
|
|
||||||
<h2>Login</h2>
|
|
||||||
<p class="auth-card-subtitle">Access your existing account</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/login">
|
|
||||||
<fieldset>
|
|
||||||
<label for="login-username">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="login-username"
|
|
||||||
name="username"
|
|
||||||
placeholder="Your username"
|
|
||||||
required
|
|
||||||
autocomplete="username"
|
|
||||||
>
|
|
||||||
|
|
||||||
<label for="login-password">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="login-password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Your password"
|
|
||||||
required
|
|
||||||
autocomplete="current-password"
|
|
||||||
>
|
|
||||||
|
|
||||||
<button class="button button-primary" type="submit">Login</button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
<!-- Register Form -->
|
||||||
<div class="auth-card">
|
<div class="auth-card frosted-glass">
|
||||||
<h2>Register</h2>
|
<h2>Register</h2>
|
||||||
<p class="auth-card-subtitle">Create a new account</p>
|
|
||||||
|
|
||||||
<form method="POST" action="/register">
|
<form method="POST" action="/register">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
@ -108,10 +87,41 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<div class="auth-card frosted-glass">
|
||||||
|
<h2>Login</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<fieldset>
|
||||||
|
<label for="login-username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
placeholder="Your username"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="login-password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Your password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button class="button button-primary" type="submit">Login</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-features-unified">
|
<div class="auth-features-unified">
|
||||||
<h3>✓ Account Benefits</h3>
|
<h3>Account Benefits</h3>
|
||||||
<div class="features-grid">
|
<div class="features-grid">
|
||||||
<div class="feature-item">Forward emails to verified addresses</div>
|
<div class="feature-item">Forward emails to verified addresses</div>
|
||||||
<div class="feature-item">Lock up to 5 inboxes to your account</div>
|
<div class="feature-item">Lock up to 5 inboxes to your account</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{% if authEnabled %}
|
{% if authEnabled %}
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Account">
|
||||||
<a href="/account" aria-label="Account settings">Settings</a>
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{message}}</h1>
|
<h1 class="page-title">{{message}}</h1>
|
||||||
<h2>{{error.status}}</h2>
|
<h2>{{error.status}}</h2>
|
||||||
<pre>{{error.stack}}</pre>
|
<pre>{{error.stack}}</pre>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<!-- Inbox Dropdown (multiple actions when logged in) -->
|
<!-- Inbox Dropdown (multiple actions when logged in) -->
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
|
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Inbox Actions">
|
||||||
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
||||||
{% if authEnabled %}
|
{% if authEnabled %}
|
||||||
{% if isLocked and hasAccess %}
|
{% if isLocked and hasAccess %}
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
{% if authEnabled %}
|
{% if authEnabled %}
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Account">
|
||||||
<a href="/account" aria-label="Account settings">Settings</a>
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
|
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,8 +53,11 @@
|
||||||
<script src="/javascripts/qrcode.js"></script>
|
<script src="/javascripts/qrcode.js"></script>
|
||||||
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script>
|
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script>
|
||||||
{% if forwardAllSuccess %}
|
{% if forwardAllSuccess %}
|
||||||
<div class="success-message">
|
<div class="alert alert-success">
|
||||||
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
Successfully forwarded {{ forwardAllSuccess }} email(s)!
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if verificationSent %}
|
{% if verificationSent %}
|
||||||
|
|
@ -63,7 +66,12 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if errorMessage %}
|
{% if errorMessage %}
|
||||||
<div class="unlock-error">
|
<div class="alert alert-error">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
|
</svg>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -84,7 +92,7 @@
|
||||||
<div class="emails-container">
|
<div class="emails-container">
|
||||||
{% for mail in mailSummaries %}
|
{% for mail in mailSummaries %}
|
||||||
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="email-link">
|
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="email-link">
|
||||||
<div class="email-card">
|
<div class="email-card frosted-glass">
|
||||||
<div class="email-header">
|
<div class="email-header">
|
||||||
<div class="email-sender">
|
<div class="email-sender">
|
||||||
<div class="sender-name">{{ mail.from[0].name }}</div>
|
<div class="sender-name">{{ mail.from[0].name }}</div>
|
||||||
|
|
@ -111,20 +119,20 @@
|
||||||
{% if authEnabled and not isLocked %}
|
{% if authEnabled and not isLocked %}
|
||||||
<!-- Lock Modal -->
|
<!-- Lock Modal -->
|
||||||
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
|
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
|
||||||
<div class="modal-content">
|
<div class="modal-content frosted-glass">
|
||||||
<span class="close" id="closeLock">×</span>
|
<span class="close" id="closeLock">×</span>
|
||||||
<h3>Lock Inbox</h3>
|
<h3>Lock Inbox</h3>
|
||||||
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
|
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
|
||||||
{% if error and error == 'locking_disabled_for_example' %}
|
{% if error and error == 'locking_disabled_for_example' %}
|
||||||
<p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p>
|
<p id="lockServerError" class="alert alert-error">Locking is disabled for the example inbox.</p>
|
||||||
{% elseif error and error == 'max_locked_inboxes' %}
|
{% elseif error and error == 'max_locked_inboxes' %}
|
||||||
<p id="lockServerError" class="unlock-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
|
<p id="lockServerError" class="alert alert-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
|
||||||
{% elseif error and error == 'already_locked' %}
|
{% elseif error and error == 'already_locked' %}
|
||||||
<p id="lockServerError" class="unlock-error">This inbox is already locked by another user.</p>
|
<p id="lockServerError" class="alert alert-error">This inbox is already locked by another user.</p>
|
||||||
{% elseif error and error == 'not_your_lock' %}
|
{% elseif error and error == 'not_your_lock' %}
|
||||||
<p id="lockServerError" class="unlock-error">You don't own the lock on this inbox.</p>
|
<p id="lockServerError" class="alert alert-error">You don't own the lock on this inbox.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p id="lockErrorInline" class="unlock-error" style="display:none"></p>
|
<p id="lockErrorInline" class="alert alert-error" style="display:none"></p>
|
||||||
<form method="POST" action="/lock/lock">
|
<form method="POST" action="/lock/lock">
|
||||||
<input type="hidden" name="address" value="{{ address }}">
|
<input type="hidden" name="address" value="{{ address }}">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|
@ -142,7 +150,7 @@
|
||||||
{% if authEnabled and isLocked and hasAccess %}
|
{% if authEnabled and isLocked and hasAccess %}
|
||||||
<!-- Remove Lock Modal -->
|
<!-- Remove Lock Modal -->
|
||||||
<div id="removeLockModal" class="modal" style="display: none;">
|
<div id="removeLockModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content frosted-glass">
|
||||||
<span class="close" id="closeRemoveLock">×</span>
|
<span class="close" id="closeRemoveLock">×</span>
|
||||||
<h3>Remove Lock</h3>
|
<h3>Remove Lock</h3>
|
||||||
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>
|
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
|
|
||||||
|
{% block metaTags %}
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="Your temporary Inbox. Create instant throwaway email addresses to protect your privacy. No registration required. Emails auto-delete after 48 hours.">
|
<meta name="description" content="Your temporary Inbox. Create instant throwaway email addresses to protect your privacy. No registration required. Emails auto-delete after 48 hours.">
|
||||||
<meta name="keywords" content="temporary email, disposable email, throwaway email, fake email, temp mail, anonymous email, 48hr email, privacy protection, burner email">
|
<meta name="keywords" content="temporary email, disposable email, throwaway email, fake email, temp mail, anonymous email, 48hr email, privacy protection, burner email">
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
<meta name="twitter:title" content="48hr.email - Your temporary Inbox">
|
<meta name="twitter:title" content="48hr.email - Your temporary Inbox">
|
||||||
<meta name="twitter:description" content="Free temporary email service. Protect your privacy with disposable email addresses.">
|
<meta name="twitter:description" content="Free temporary email service. Protect your privacy with disposable email addresses.">
|
||||||
<meta name="twitter:image" content="https://48hr.email/images/logo.png">
|
<meta name="twitter:image" content="https://48hr.email/images/logo.png">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Additional Meta Tags -->
|
<!-- Additional Meta Tags -->
|
||||||
<meta name="theme-color" content="#9b4dca">
|
<meta name="theme-color" content="#9b4dca">
|
||||||
|
|
@ -74,6 +76,8 @@
|
||||||
<script src="/javascripts/utils.js"></script>
|
<script src="/javascripts/utils.js"></script>
|
||||||
<script src="/socket.io/socket.io.js" defer="true"></script>
|
<script src="/socket.io/socket.io.js" defer="true"></script>
|
||||||
<script src="/javascripts/notifications.js" defer="true"></script>
|
<script src="/javascripts/notifications.js" defer="true"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer="true"></script>
|
||||||
|
<script src="/javascripts/stats.js" defer="true"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}>
|
<body{% if bodyClass %} class="{{ bodyClass }}"{% endif %}>
|
||||||
|
|
@ -90,7 +94,11 @@
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
<section class="container footer">
|
<section class="container footer">
|
||||||
<hr>
|
<hr>
|
||||||
<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 {{ totalcount | raw }}</h4>
|
{% if config.http.statisticsEnabled %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
<h4 class="container footer-two"> This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a></h4>
|
<h4 class="container footer-two"> This project is <a href="https://github.com/crazyco-xyz/48hr.email" style="text-decoration:underline" target="_blank">open-source ♥</a></h4>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
{% set bodyClass = 'loading-page' %}
|
{% set bodyClass = 'loading-page' %}
|
||||||
|
|
||||||
|
{% block title %}Loading... | {{ branding[0] }}{% endblock %}
|
||||||
|
|
||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
{% if authEnabled %}
|
{% if authEnabled %}
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Account">
|
||||||
<a href="/account" aria-label="Account settings">Settings</a>
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,33 +32,105 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="login">
|
<div class="homepage-container">
|
||||||
<h1>Welcome!</h1>
|
<div class="hero-section">
|
||||||
<h4>Here you can either create a new Inbox, or access your old one</h4>
|
<h1 class="page-title hero-title">Your Temporary Inbox</h1>
|
||||||
|
<p class="hero-subtitle">Create instant disposable email addresses. No registration required. Emails auto-delete after {{ purgeTimeRaw|readablePurgeTime }}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if userInputError %}
|
<div class="inbox-creator frosted-glass">
|
||||||
<blockquote class="warning">
|
<h2 class="creator-title">Get Started</h2>
|
||||||
Your input was invalid. Please try other values.
|
|
||||||
</blockquote>
|
{% if userInputError %}
|
||||||
{% endif %}
|
<div class="alert alert-warning">
|
||||||
<form method="POST" action="/">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
<fieldset>
|
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
|
||||||
<label for="nameField">Name</label>
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
<input type="text" id="nameField" name="username" value="{{ username }}">
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
<label for="commentField">Domain ({{ domains|length }})</label>
|
</svg>
|
||||||
<div class="dropdown">
|
Your input was invalid. Please try other values.
|
||||||
<select id="commentField" name="domain">
|
|
||||||
{% for domain in domains %}
|
|
||||||
<option value="{{ domain }}">{{ domain }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons">
|
{% endif %}
|
||||||
<input class="button" type="submit" value="Access This Inbox">
|
|
||||||
<a class="button" href="/inbox/random">Create Random Inbox</a>
|
<form method="POST" action="/" class="inbox-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nameField">Choose Your Name</label>
|
||||||
|
<input type="text" id="nameField" name="username" value="{{ username }}" placeholder="e.g., john.doe" required>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
</form>
|
<div class="form-group">
|
||||||
|
<label for="commentField">Select Domain</label>
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select id="commentField" name="domain">
|
||||||
|
{% for domain in domains %}
|
||||||
|
<option value="{{ domain }}">@{{ domain }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="domain-count">{{ domains|length }} domains available</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<span>Access Inbox</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="/inbox/random" class="btn btn-secondary">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
<span>Random Inbox</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card frosted-glass">
|
||||||
|
<h3>Instant Access</h3>
|
||||||
|
<p>Create unlimited temporary email addresses in seconds. No waiting, no verification.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card frosted-glass">
|
||||||
|
<h3>Auto-Delete</h3>
|
||||||
|
<p>All emails automatically purge after {{ purgeTimeRaw|readablePurgeTime }}. Clean and secure by default.</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<h3>Common Use Cases</h3>
|
||||||
|
<ul class="use-cases-list">
|
||||||
|
<li><strong>Avoid Spam:</strong> Sign up for services without cluttering your primary inbox</li>
|
||||||
|
<li><strong>Test Services:</strong> Try new apps and websites without commitment</li>
|
||||||
|
<li><strong>Online Privacy:</strong> Keep your real email address private from third parties</li>
|
||||||
|
<li><strong>One-Time Verification:</strong> Receive verification codes without long-term exposure</li>
|
||||||
|
<li><strong>Download Content:</strong> Access gated content that requires email confirmation</li>
|
||||||
|
<li><strong>Form Testing:</strong> Test email workflows during development</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Why Choose {{ branding[0] }}?</h3>
|
||||||
|
<ul class="benefits-list">
|
||||||
|
<li><strong>No Sign-Up Required:</strong> Start using immediately without creating an account</li>
|
||||||
|
<li><strong>Multiple Domains:</strong> Choose from several domain options for flexibility</li>
|
||||||
|
<li><strong>Clean Interface:</strong> Simple, modern design focused on usability</li>
|
||||||
|
<li><strong>Real-Time Updates:</strong> See new emails arrive instantly without refreshing</li>
|
||||||
|
<li><strong>Open Source:</strong> Transparent codebase you can review and trust</li>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<!-- Email Dropdown (multiple actions when logged in) -->
|
<!-- Email Dropdown (multiple actions when logged in) -->
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
|
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Email Actions">
|
||||||
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
|
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
|
||||||
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
|
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
|
||||||
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
{% if authEnabled %}
|
{% if authEnabled %}
|
||||||
<div class="action-dropdown">
|
<div class="action-dropdown">
|
||||||
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu" data-section-title="Account">
|
||||||
<a href="/account" aria-label="Account settings">Settings</a>
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
|
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -48,8 +48,11 @@
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% if forwardSuccess %}
|
{% if forwardSuccess %}
|
||||||
<div class="success-message">
|
<div class="alert alert-success">
|
||||||
✓ Email forwarded successfully!
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
Email forwarded successfully!
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if verificationSent %}
|
{% if verificationSent %}
|
||||||
|
|
@ -125,7 +128,7 @@
|
||||||
|
|
||||||
<!-- Forward Email Modal -->
|
<!-- Forward Email Modal -->
|
||||||
<div id="forwardModal" class="modal" style="display: none;">
|
<div id="forwardModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content frosted-glass">
|
||||||
<span class="close" id="closeForward">×</span>
|
<span class="close" id="closeForward">×</span>
|
||||||
<h3>Forward Email</h3>
|
<h3>Forward Email</h3>
|
||||||
|
|
||||||
|
|
@ -143,9 +146,9 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="modal-description">Select a verified email address to forward this message to.</p>
|
<p class="modal-description">Select a verified email address to forward this message to.</p>
|
||||||
{% if errorMessage %}
|
{% if errorMessage %}
|
||||||
<p class="unlock-error">{{ errorMessage }}</p>
|
<p class="alert alert-error">{{ errorMessage }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p id="forwardError" class="unlock-error" style="display:none"></p>
|
<p id="forwardError" class="alert alert-error" style="display:none"></p>
|
||||||
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
|
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="forwardEmail" class="floating-label">Forward to</label>
|
<label for="forwardEmail" class="floating-label">Forward to</label>
|
||||||
|
|
|
||||||
104
infrastructure/web/views/stats.twig
Normal file
104
infrastructure/web/views/stats.twig
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
{% extends 'layout.twig' %}
|
||||||
|
|
||||||
|
{% 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 }}.">
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://48hr.email/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: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: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">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="action-links">
|
||||||
|
{% if currentUser %}
|
||||||
|
<!-- Account Dropdown (logged in) -->
|
||||||
|
{% if authEnabled %}
|
||||||
|
<div class="action-dropdown">
|
||||||
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
|
<div class="dropdown-menu" data-section-title="Account">
|
||||||
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
|
<a href="/logout?redirect=/stats" aria-label="Logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if authEnabled %}
|
||||||
|
<a href="/auth" aria-label="Login or Register">Account</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/" aria-label="Return to home">Home</a>
|
||||||
|
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
||||||
|
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="stats-container">
|
||||||
|
<h1 class="page-title">Email Statistics</h1>
|
||||||
|
<p class="stats-subtitle">Historical patterns, real-time activity, and predictions over {{ purgeTime|striptags }}</p>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<!-- Current Count -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="currentCount">{{ stats.currentCount }}</div>
|
||||||
|
<div class="stat-label">Emails in System</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All-Time Total -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="historicalTotal">{{ stats.allTimeTotal }}</div>
|
||||||
|
<div class="stat-label">All-Time Total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Receives (Purge Window) -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="receives24h">{{ stats.last24Hours.receives }}</div>
|
||||||
|
<div class="stat-label">Received</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deletes (Purge Window) -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="deletes24h">{{ stats.last24Hours.deletes }}</div>
|
||||||
|
<div class="stat-label">Deleted</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forwards (Purge Window) -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="forwards24h">{{ stats.last24Hours.forwards }}</div>
|
||||||
|
<div class="stat-label">Forwarded</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h2>Email Activity Timeline</h2>
|
||||||
|
<canvas id="statsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set initial data for stats.js to consume
|
||||||
|
window.initialStatsData = {{ stats.last24Hours.timeline|json_encode|raw }};
|
||||||
|
window.historicalData = {{ stats.historical|json_encode|raw }};
|
||||||
|
window.predictionData = {{ stats.prediction|json_encode|raw }};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const sanitizeHtml = require('sanitize-html')
|
const sanitizeHtml = require('sanitize-html')
|
||||||
|
const config = require('../../../application/config')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
|
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
|
||||||
|
|
@ -6,22 +7,84 @@ const sanitizeHtml = require('sanitize-html')
|
||||||
* @returns {*} dom after transformation
|
* @returns {*} dom after transformation
|
||||||
*/
|
*/
|
||||||
exports.sanitizeHtmlTwigFilter = function(value) {
|
exports.sanitizeHtmlTwigFilter = function(value) {
|
||||||
return sanitizeHtml(value, {
|
return sanitizeHtml(value, {
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
a: ['href', 'target', 'rel']
|
a: ['href', 'target', 'rel']
|
||||||
},
|
},
|
||||||
|
|
||||||
transformTags: {
|
transformTags: {
|
||||||
a(tagName, attribs) {
|
a(tagName, attribs) {
|
||||||
return {
|
return {
|
||||||
tagName,
|
tagName,
|
||||||
attribs: {
|
attribs: {
|
||||||
rel: 'noreferrer noopener',
|
rel: 'noreferrer noopener',
|
||||||
href: attribs.href,
|
href: attribs.href,
|
||||||
target: '_blank'
|
target: '_blank'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert time to highest possible unit (minutes → hours → days),
|
||||||
|
* rounding if necessary and prefixing "~" when rounded.
|
||||||
|
* Mirrors the logic from Helper.convertAndRound()
|
||||||
|
*
|
||||||
|
* @param {number} time
|
||||||
|
* @param {string} unit "minutes" | "hours" | "days"
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function convertAndRound(time, unit) {
|
||||||
|
let value = time
|
||||||
|
let u = unit
|
||||||
|
|
||||||
|
// upgrade units
|
||||||
|
const units = [
|
||||||
|
["minutes", 60, "hours"],
|
||||||
|
["hours", 24, "days"]
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [from, factor, to] of units) {
|
||||||
|
if (u === from && value > factor) {
|
||||||
|
value = value / factor
|
||||||
|
u = to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine if rounding is needed
|
||||||
|
const rounded = !Number.isSafeInteger(value)
|
||||||
|
if (rounded) value = Math.round(value)
|
||||||
|
|
||||||
|
// Handle singular/plural
|
||||||
|
const displayValue = value === 1 ? value : value
|
||||||
|
const displayUnit = value === 1 ? u.replace(/s$/, '') : u
|
||||||
|
|
||||||
|
return `${rounded ? "~" : ""}${displayValue} ${displayUnit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert purgeTime config to readable format, respecting the convert flag
|
||||||
|
* @param {Object} purgeTime - Object with time, unit, and convert properties
|
||||||
|
* @returns {String} Readable time string
|
||||||
|
*/
|
||||||
|
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}`
|
||||||
|
|
||||||
|
// Only convert if the convert flag is true
|
||||||
|
if (purgeTime.convert) {
|
||||||
|
result = convertAndRound(purgeTime.time, purgeTime.unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ const errorRouter = require('./routes/error')
|
||||||
const lockRouter = require('./routes/lock')
|
const lockRouter = require('./routes/lock')
|
||||||
const authRouter = require('./routes/auth')
|
const authRouter = require('./routes/auth')
|
||||||
const accountRouter = require('./routes/account')
|
const accountRouter = require('./routes/account')
|
||||||
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
|
const statsRouter = require('./routes/stats')
|
||||||
|
const { sanitizeHtmlTwigFilter, readablePurgeTime } = require('./views/twig-filters')
|
||||||
|
|
||||||
const Helper = require('../../application/helper')
|
const Helper = require('../../application/helper')
|
||||||
const helper = new(Helper)
|
const helper = new(Helper)
|
||||||
|
|
@ -89,10 +90,12 @@ app.use(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
|
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
|
||||||
|
Twig.extendFilter('readablePurgeTime', readablePurgeTime)
|
||||||
|
|
||||||
// Middleware to expose user session to all templates
|
// Middleware to expose user session to all templates
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.locals.authEnabled = config.user.authEnabled
|
res.locals.authEnabled = config.user.authEnabled
|
||||||
|
res.locals.config = config
|
||||||
res.locals.currentUser = null
|
res.locals.currentUser = null
|
||||||
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
|
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
|
||||||
res.locals.currentUser = {
|
res.locals.currentUser = {
|
||||||
|
|
@ -103,6 +106,21 @@ app.use((req, res, next) => {
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Middleware to expose mail count to all templates
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const mailProcessingService = req.app.get('mailProcessingService')
|
||||||
|
const Helper = require('../../application/helper')
|
||||||
|
const helper = new Helper()
|
||||||
|
|
||||||
|
if (mailProcessingService) {
|
||||||
|
const count = mailProcessingService.getCount()
|
||||||
|
res.locals.mailCount = helper.mailCountBuilder(count)
|
||||||
|
} else {
|
||||||
|
res.locals.mailCount = ''
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
// Middleware to show loading page until IMAP is ready
|
// Middleware to show loading page until IMAP is ready
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const isImapReady = req.app.get('isImapReady')
|
const isImapReady = req.app.get('isImapReady')
|
||||||
|
|
@ -120,6 +138,7 @@ if (config.user.authEnabled) {
|
||||||
app.use('/inbox', inboxRouter)
|
app.use('/inbox', inboxRouter)
|
||||||
app.use('/error', errorRouter)
|
app.use('/error', errorRouter)
|
||||||
app.use('/lock', lockRouter)
|
app.use('/lock', lockRouter)
|
||||||
|
app.use('/stats', statsRouter)
|
||||||
|
|
||||||
// Catch 404 and forward to error handler
|
// Catch 404 and forward to error handler
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
@ -130,8 +149,6 @@ app.use((req, res, next) => {
|
||||||
app.use(async(err, req, res, _next) => {
|
app.use(async(err, req, res, _next) => {
|
||||||
try {
|
try {
|
||||||
debug('Error handler triggered:', err.message)
|
debug('Error handler triggered:', err.message)
|
||||||
const mailProcessingService = req.app.get('mailProcessingService')
|
|
||||||
const count = await mailProcessingService.getCount()
|
|
||||||
|
|
||||||
// Set locals, only providing error in development
|
// Set locals, only providing error in development
|
||||||
res.locals.message = err.message
|
res.locals.message = err.message
|
||||||
|
|
@ -142,7 +159,6 @@ app.use(async(err, req, res, _next) => {
|
||||||
res.render('error', {
|
res.render('error', {
|
||||||
purgeTime: purgeTime,
|
purgeTime: purgeTime,
|
||||||
address: req.params && req.params.address,
|
address: req.params && req.params.address,
|
||||||
count: count,
|
|
||||||
branding: config.http.branding
|
branding: config.http.branding
|
||||||
})
|
})
|
||||||
} catch (renderError) {
|
} catch (renderError) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "48hr.email",
|
"name": "48hr.email",
|
||||||
"version": "1.9.0",
|
"version": "2.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
||||||
12
schema.sql
12
schema.sql
|
|
@ -44,6 +44,18 @@ CREATE INDEX IF NOT EXISTS idx_locked_inboxes_user_id ON user_locked_inboxes(use
|
||||||
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address);
|
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address);
|
||||||
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
|
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
|
||||||
|
|
||||||
|
-- Statistics storage for persistence across restarts
|
||||||
|
CREATE TABLE IF NOT EXISTS statistics (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single row table
|
||||||
|
largest_uid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hourly_data TEXT, -- JSON array of 24h rolling data
|
||||||
|
last_updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Initialize with default row if not exists
|
||||||
|
INSERT OR IGNORE INTO statistics (id, largest_uid, hourly_data, last_updated)
|
||||||
|
VALUES (1, 0, '[]', 0);
|
||||||
|
|
||||||
-- Trigger to enforce max 5 locked inboxes per user
|
-- Trigger to enforce max 5 locked inboxes per user
|
||||||
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
|
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
|
||||||
BEFORE INSERT ON user_locked_inboxes
|
BEFORE INSERT ON user_locked_inboxes
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue