[Feat]; Add user functionality

Add dashboard and update routes to use the new User object. Merge forwarding and locking to be user-only methods and remove old routes that no longer exist
This commit is contained in:
ClaraCrazy 2026-01-02 18:49:57 +01:00
parent 598cea9b9c
commit 004d764238
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
22 changed files with 1598 additions and 502 deletions

View file

@ -30,7 +30,6 @@ SMTP_PORT=465 # SMTP port (587
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports) 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_FROM_NAME="48hr Email Service" # Display name for forwarded emails
# --- HTTP / WEB CONFIGURATION --- # --- HTTP / WEB CONFIGURATION ---
HTTP_PORT=3000 # Port HTTP_PORT=3000 # Port
@ -51,8 +50,7 @@ USER_AUTH_ENABLED=false # Enable user re
USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking) USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
# Database Paths # Database Paths
USER_DATABASE_PATH="./db/users.db" # Path to user database USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks)
LOCK_DATABASE_PATH="./db/locked-inboxes.db" # Path to lock database
# Feature Limits # Feature Limits
USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user

45
app.js
View file

@ -4,6 +4,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 { 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')
@ -20,31 +21,23 @@ const clientNotification = new ClientNotification()
debug('Client notification service initialized') debug('Client notification service initialized')
clientNotification.use(io) clientNotification.use(io)
// Initialize inbox locking (always available for registered users)
const inboxLock = new InboxLock(config.user.lockDbPath)
app.set('inboxLock', inboxLock)
debug('Inbox lock service initialized')
// Check for inactive locked inboxes
setInterval(() => {
const inactive = inboxLock.getInactive(config.user.lockReleaseHours)
if (inactive.length > 0) {
debug(`Releasing ${inactive.length} inactive locked inbox(es)`)
inactive.forEach(address => inboxLock.release(address))
}
}, config.imap.refreshIntervalSeconds * 1000)
const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized')
const smtpService = new SmtpService(config) const smtpService = new SmtpService(config)
debug('SMTP service initialized') debug('SMTP service initialized')
app.set('smtpService', smtpService)
const verificationStore = new VerificationStore() const verificationStore = new VerificationStore()
debug('Verification store initialized') debug('Verification store initialized')
app.set('verificationStore', verificationStore) app.set('verificationStore', verificationStore)
// Set config in app for route access
app.set('config', config)
// Initialize user repository and auth service (if enabled) // Initialize user repository and auth service (if enabled)
let inboxLock = null
if (config.user.authEnabled) { if (config.user.authEnabled) {
// Migrate legacy database files for backwards compatibility
Helper.migrateDatabase(config.user.databasePath)
const userRepository = new UserRepository(config.user.databasePath) const userRepository = new UserRepository(config.user.databasePath)
debug('User repository initialized') debug('User repository initialized')
app.set('userRepository', userRepository) app.set('userRepository', userRepository)
@ -52,13 +45,33 @@ if (config.user.authEnabled) {
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)
// Initialize inbox locking with user repository
inboxLock = new InboxLock(userRepository)
app.set('inboxLock', inboxLock)
debug('Inbox lock service initialized (user-based)')
// Check for inactive locked inboxes
setInterval(() => {
const inactive = inboxLock.getInactive(config.user.lockReleaseHours)
if (inactive.length > 0) {
debug(`Found ${inactive.length} inactive locked inbox(es)`)
// Note: Auto-release of user locks would require storing userId
// For now, inactive locks remain until user logs in
}
}, config.imap.refreshIntervalSeconds * 1000)
console.log('User authentication system enabled') console.log('User authentication system enabled')
} else { } else {
app.set('userRepository', null) app.set('userRepository', null)
app.set('authService', null) app.set('authService', null)
app.set('inboxLock', null)
debug('User authentication system disabled') debug('User authentication system disabled')
} }
const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized')
const mailProcessingService = new MailProcessingService( const mailProcessingService = new MailProcessingService(
new MailRepository(), new MailRepository(),
imapService, imapService,

View file

@ -62,8 +62,7 @@ const config = {
port: Number(process.env.SMTP_PORT) || 465, port: Number(process.env.SMTP_PORT) || 465,
secure: parseBool(process.env.SMTP_SECURE) || true, 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)
fromName: parseValue(process.env.SMTP_FROM_NAME) || '48hr.email Forwarding'
}, },
http: { http: {
@ -79,8 +78,7 @@ const config = {
authEnabled: parseBool(process.env.USER_AUTH_ENABLED) || false, authEnabled: parseBool(process.env.USER_AUTH_ENABLED) || false,
// Database // Database
databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/users.db', databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/data.db',
lockDbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db',
// Session & Auth // Session & Auth
sessionSecret: parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production', sessionSecret: parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production',

View file

@ -232,6 +232,36 @@ class Helper {
return false return false
} }
} }
/**
* Migrate legacy database files for backwards compatibility
* - Renames users.db to data.db if it exists
* - Logs if locked-inboxes.db exists (no longer needed)
* @param {string} dbPath - Path to the current database (data.db)
*/
static migrateDatabase(dbPath) {
const fs = require('fs')
const path = require('path')
const dbDir = path.dirname(dbPath)
const legacyUsersDb = path.join(dbDir, 'users.db')
const legacyLockedInboxesDb = path.join(dbDir, 'locked-inboxes.db')
// Migrate users.db to data.db
if (fs.existsSync(legacyUsersDb) && !fs.existsSync(dbPath)) {
console.log(`Migrating ${legacyUsersDb}${dbPath}`)
fs.renameSync(legacyUsersDb, dbPath)
debug(`Database migrated: users.db → data.db`)
}
// Warn about old locked-inboxes.db
if (fs.existsSync(legacyLockedInboxesDb)) {
console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`)
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.`)
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')
}
}
} }
module.exports = Helper module.exports = Helper

View file

@ -261,7 +261,8 @@ class MailProcessingService extends EventEmitter {
// Forward via SMTP service // Forward via SMTP service
debug(`Forwarding email to ${destinationEmail}`) debug(`Forwarding email to ${destinationEmail}`)
const result = await this.smtpService.forwardMail(fullMail, destinationEmail) const branding = this.config.http.branding[0] || '48hr.email'
const result = await this.smtpService.forwardMail(fullMail, destinationEmail, branding)
if (result.success) { if (result.success) {
debug(`Email forwarded successfully. MessageId: ${result.messageId}`) debug(`Email forwarded successfully. MessageId: ${result.messageId}`)

View file

@ -65,7 +65,7 @@ class SmtpService {
* @param {string} destinationEmail - Email address to forward to * @param {string} destinationEmail - Email address to forward to
* @returns {Promise<{success: boolean, error?: string, messageId?: string}>} * @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
*/ */
async forwardMail(mail, destinationEmail) { async forwardMail(mail, destinationEmail, branding = '48hr.email') {
if (!this.transporter) { if (!this.transporter) {
return { return {
success: false, success: false,
@ -83,7 +83,7 @@ class SmtpService {
try { try {
debug(`Forwarding email (Subject: "${mail.subject}") to ${destinationEmail}`) debug(`Forwarding email (Subject: "${mail.subject}") to ${destinationEmail}`)
const forwardMessage = this._buildForwardMessage(mail, destinationEmail) const forwardMessage = this._buildForwardMessage(mail, destinationEmail, branding)
const info = await this.transporter.sendMail(forwardMessage) const info = await this.transporter.sendMail(forwardMessage)
@ -106,10 +106,11 @@ class SmtpService {
* Build the forward message structure * Build the forward message structure
* @param {Object} mail - Parsed email object * @param {Object} mail - Parsed email object
* @param {string} destinationEmail - Destination address * @param {string} destinationEmail - Destination address
* @param {string} branding - Service branding name
* @returns {Object} - Nodemailer message object * @returns {Object} - Nodemailer message object
* @private * @private
*/ */
_buildForwardMessage(mail, destinationEmail) { _buildForwardMessage(mail, destinationEmail, branding = '48hr.email') {
// Extract original sender info // Extract original sender info
const originalFrom = (mail.from && mail.from.text) || 'Unknown Sender' const originalFrom = (mail.from && mail.from.text) || 'Unknown Sender'
const originalTo = (mail.to && mail.to.text) || 'Unknown Recipient' const originalTo = (mail.to && mail.to.text) || 'Unknown Recipient'
@ -138,7 +139,7 @@ To: ${originalTo}
// Build the message object // Build the message object
const message = { const message = {
from: { from: {
name: this.config.smtp.fromName, name: branding,
address: this.config.smtp.user address: this.config.smtp.user
}, },
to: destinationEmail, to: destinationEmail,
@ -224,9 +225,10 @@ ${mail.html}
* @param {string} token - Verification token * @param {string} token - Verification token
* @param {string} baseUrl - Base URL for verification link * @param {string} baseUrl - Base URL for verification link
* @param {string} branding - Service branding name * @param {string} branding - Service branding name
* @param {string} verifyPath - Verification path (default: /inbox/verify)
* @returns {Promise<{success: boolean, error?: string, messageId?: string}>} * @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
*/ */
async sendVerificationEmail(destinationEmail, token, baseUrl, branding = '48hr.email') { async sendVerificationEmail(destinationEmail, token, baseUrl, branding = '48hr.email', verifyPath = '/inbox/verify') {
if (!this.transporter) { if (!this.transporter) {
return { return {
success: false, success: false,
@ -234,7 +236,7 @@ ${mail.html}
} }
} }
const verificationLink = `${baseUrl}/inbox/verify?token=${token}` const verificationLink = `${baseUrl}${verifyPath}?token=${token}`
const htmlContent = ` const htmlContent = `
<!DOCTYPE html> <!DOCTYPE html>

View file

@ -1,95 +1,222 @@
const Database = require('better-sqlite3')
const bcrypt = require('bcrypt') const bcrypt = require('bcrypt')
const path = require('path') const debug = require('debug')('48hr-email:inbox-lock')
/**
* InboxLock - Manages inbox locking for registered users
* Uses user_locked_inboxes table from the users database
*/
class InboxLock { class InboxLock {
constructor(dbPath = './db/locked-inboxes.db') { constructor(userRepository) {
// Ensure data directory exists this.userRepository = userRepository
const fs = require('fs') this.db = userRepository.db
const dir = path.dirname(dbPath) debug('InboxLock initialized with user database')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this._initTable()
} }
_initTable() { /**
this.db.exec(` * Lock an inbox for a user (no separate password needed - uses account ownership)
CREATE TABLE IF NOT EXISTS locked_inboxes ( * @param {number} userId - User ID
address TEXT PRIMARY KEY, * @param {string} address - Inbox address to lock
password_hash TEXT NOT NULL, * @returns {Promise<boolean>} - Success status
locked_at INTEGER NOT NULL, */
last_access INTEGER NOT NULL async lock(userId, address) {
)
`)
}
async lock(address, password) {
const passwordHash = await bcrypt.hash(password, 10)
const now = Date.now()
const stmt = this.db.prepare(`
INSERT INTO locked_inboxes (address, password_hash, locked_at, last_access)
VALUES (?, ?, ?, ?)
`)
try { try {
stmt.run(address.toLowerCase(), passwordHash, now, now) // Check if user can lock more inboxes (5 max)
return true if (!this.canLockMore(userId)) {
} catch (error) { throw new Error('You have reached the maximum of 5 locked inboxes')
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { }
// Check if inbox is already locked
if (this.isLocked(address)) {
throw new Error('This inbox is already locked') throw new Error('This inbox is already locked')
} }
const now = Date.now()
const stmt = this.db.prepare(`
INSERT INTO user_locked_inboxes (user_id, inbox_address, password_hash, locked_at, last_accessed)
VALUES (?, ?, ?, ?, ?)
`)
// Use empty password hash since we rely on user authentication
stmt.run(userId, address.toLowerCase(), '', now, now)
debug(`Inbox ${address} locked by user ${userId}`)
return true
} catch (error) {
debug(`Failed to lock inbox ${address}:`, error.message)
throw error throw error
} }
} }
async unlock(address, password) { /**
const stmt = this.db.prepare('SELECT * FROM locked_inboxes WHERE address = ?') * Unlock an inbox (verify user owns the lock)
const inbox = stmt.get(address.toLowerCase()) * @param {number} userId - User ID attempting to unlock
* @param {string} address - Inbox address to unlock
* @returns {Promise<Object|null>} - Lock info if successful, null if failed
*/
async unlock(userId, address) {
try {
const stmt = this.db.prepare(`
SELECT * FROM user_locked_inboxes
WHERE user_id = ? AND inbox_address = ?
`)
const lock = stmt.get(userId, address.toLowerCase())
if (!inbox) { if (!lock) {
debug(`No lock found for user ${userId} on inbox ${address}`)
return null
}
// Update last access
this.updateAccess(userId, address)
debug(`Inbox ${address} unlocked by user ${userId}`)
return lock
} catch (error) {
debug(`Error unlocking inbox ${address}:`, error.message)
return null return null
} }
const valid = await bcrypt.compare(password, inbox.password_hash)
if (!valid) {
return null
}
// Update last access
this.updateAccess(address)
return inbox
} }
/**
* Check if an inbox is locked by any user
* @param {string} address - Inbox address
* @returns {boolean} - True if locked
*/
isLocked(address) { isLocked(address) {
const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE address = ?') const stmt = this.db.prepare(`
return stmt.get(address.toLowerCase()) !== undefined SELECT inbox_address FROM user_locked_inboxes
WHERE inbox_address = ?
`)
const result = stmt.get(address.toLowerCase())
return result !== undefined
} }
updateAccess(address) { /**
const stmt = this.db.prepare('UPDATE locked_inboxes SET last_access = ? WHERE address = ?') * Check if an inbox is locked by a specific user
stmt.run(Date.now(), address.toLowerCase()) * @param {string} address - Inbox address
* @param {number} userId - User ID
* @returns {boolean} - True if locked by this user
*/
isLockedByUser(address, userId) {
const stmt = this.db.prepare(`
SELECT inbox_address FROM user_locked_inboxes
WHERE inbox_address = ? AND user_id = ?
`)
const result = stmt.get(address.toLowerCase(), userId)
return result !== undefined
} }
/**
* Update last access timestamp for a locked inbox
* @param {number} userId - User ID
* @param {string} address - Inbox address
*/
updateAccess(userId, address) {
const stmt = this.db.prepare(`
UPDATE user_locked_inboxes
SET last_accessed = ?
WHERE user_id = ? AND inbox_address = ?
`)
stmt.run(Date.now(), userId, address.toLowerCase())
debug(`Updated last access for inbox ${address} by user ${userId}`)
}
/**
* Get inactive locked inboxes (not accessed in X hours)
* @param {number} hoursThreshold - Hours of inactivity
* @returns {Array<string>} - Array of inactive inbox addresses
*/
getInactive(hoursThreshold) { getInactive(hoursThreshold) {
const cutoff = Date.now() - (hoursThreshold * 60 * 60 * 1000) const cutoff = Date.now() - (hoursThreshold * 60 * 60 * 1000)
const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE last_access < ?') const stmt = this.db.prepare(`
return stmt.all(cutoff).map(row => row.address) SELECT inbox_address FROM user_locked_inboxes
WHERE last_accessed < ?
`)
return stmt.all(cutoff).map(row => row.inbox_address)
} }
release(address) { /**
const stmt = this.db.prepare('DELETE FROM locked_inboxes WHERE address = ?') * Release (unlock) an inbox
stmt.run(address.toLowerCase()) * @param {number} userId - User ID
* @param {string} address - Inbox address to release
*/
release(userId, address) {
const stmt = this.db.prepare(`
DELETE FROM user_locked_inboxes
WHERE user_id = ? AND inbox_address = ?
`)
stmt.run(userId, address.toLowerCase())
debug(`Released lock on inbox ${address} by user ${userId}`)
} }
/**
* Get all locked inboxes (for admin/debugging)
* @returns {Array<string>} - Array of all locked inbox addresses
*/
getAllLocked() { getAllLocked() {
const stmt = this.db.prepare('SELECT address FROM locked_inboxes') const stmt = this.db.prepare('SELECT inbox_address FROM user_locked_inboxes')
return stmt.all().map(row => row.address) return stmt.all().map(row => row.inbox_address)
}
/**
* Get all locked inboxes for a specific user
* @param {number} userId - User ID
* @returns {Array<Object>} - Array of locked inbox objects with metadata
*/
getUserLockedInboxes(userId) {
const stmt = this.db.prepare(`
SELECT inbox_address, locked_at, last_accessed
FROM user_locked_inboxes
WHERE user_id = ?
ORDER BY locked_at DESC
`)
const inboxes = stmt.all(userId)
return inboxes.map(inbox => ({
address: inbox.inbox_address,
lockedAt: inbox.locked_at,
lastAccess: inbox.last_accessed,
lastAccessedAgo: this._formatTimeAgo(inbox.last_accessed)
}))
}
/**
* Check if user can lock more inboxes (5 max)
* @param {number} userId - User ID
* @returns {boolean} - True if user can lock more
*/
canLockMore(userId) {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM user_locked_inboxes
WHERE user_id = ?
`)
const result = stmt.get(userId)
return result.count < 5
}
/**
* Get count of locked inboxes for a user
* @param {number} userId - User ID
* @returns {number} - Number of locked inboxes
*/
getUserLockedCount(userId) {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM user_locked_inboxes
WHERE user_id = ?
`)
const result = stmt.get(userId)
return result.count
}
_formatTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
return `${Math.floor(seconds / 86400)} days ago`
} }
} }
module.exports = InboxLock module.exports = InboxLock

View file

@ -186,7 +186,7 @@ class UserRepository {
/** /**
* Get all verified forwarding emails for a user * Get all verified forwarding emails for a user
* @param {number} userId * @param {number} userId
* @returns {Array} - Array of email objects * @returns {Array} - Array of email objects with formatted timestamps
*/ */
getForwardEmails(userId) { getForwardEmails(userId) {
try { try {
@ -197,14 +197,37 @@ class UserRepository {
ORDER BY created_at DESC ORDER BY created_at DESC
`) `)
const emails = stmt.all(userId) const emails = stmt.all(userId)
// Add formatted timestamp
const formatted = emails.map(email => ({
...email,
verifiedAgo: this._formatTimeAgo(email.verified_at)
}))
debug(`Found ${emails.length} forward emails for user ${userId}`) debug(`Found ${emails.length} forward emails for user ${userId}`)
return emails return formatted
} catch (error) { } catch (error) {
debug(`Error getting forward emails: ${error.message}`) debug(`Error getting forward emails: ${error.message}`)
throw error throw error
} }
} }
/**
* Format timestamp to relative time
* @param {number} timestamp - Unix timestamp in milliseconds
* @returns {string} - Formatted time ago string
* @private
*/
_formatTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
if (seconds < 2592000) return `${Math.floor(seconds / 86400)} days ago`
return `${Math.floor(seconds / 2592000)} months ago`
}
/** /**
* Check if user has a specific forwarding email * Check if user has a specific forwarding email
* @param {number} userId * @param {number} userId
@ -276,9 +299,10 @@ class UserRepository {
/** /**
* Get user statistics * Get user statistics
* @param {number} userId * @param {number} userId
* @returns {Object} - {lockedInboxesCount, forwardEmailsCount, accountAge} * @param {Object} config - Application configuration
* @returns {Object} - {lockedInboxesCount, forwardEmailsCount, accountAge, maxLockedInboxes, maxForwardEmails, lockReleaseHours}
*/ */
getUserStats(userId) { getUserStats(userId, config = {}) {
try { try {
const user = this.getUserById(userId) const user = this.getUserById(userId)
if (!user) { if (!user) {
@ -294,7 +318,8 @@ class UserRepository {
const lockedInboxesCount = lockedInboxesStmt.get(userId).count const lockedInboxesCount = lockedInboxesStmt.get(userId).count
const forwardEmailsCount = forwardEmailsStmt.get(userId).count const forwardEmailsCount = forwardEmailsStmt.get(userId).count
const accountAge = Date.now() - user.created_at const accountAgeMs = Date.now() - user.created_at
const accountAge = this._formatAccountAge(accountAgeMs)
debug(`Stats for user ${userId}: ${lockedInboxesCount} locked inboxes, ${forwardEmailsCount} forward emails`) debug(`Stats for user ${userId}: ${lockedInboxesCount} locked inboxes, ${forwardEmailsCount} forward emails`)
@ -303,7 +328,10 @@ class UserRepository {
forwardEmailsCount, forwardEmailsCount,
accountAge, accountAge,
createdAt: user.created_at, createdAt: user.created_at,
lastLogin: user.last_login lastLogin: user.last_login,
maxLockedInboxes: config.maxLockedInboxes || 5,
maxForwardEmails: config.maxForwardEmails || 5,
lockReleaseHours: config.lockReleaseHours || 720
} }
} catch (error) { } catch (error) {
debug(`Error getting user stats: ${error.message}`) debug(`Error getting user stats: ${error.message}`)
@ -311,6 +339,21 @@ class UserRepository {
} }
} }
/**
* Format account age in human-readable format
* @param {number} ms - Milliseconds since account creation
* @returns {string} - Formatted age
* @private
*/
_formatAccountAge(ms) {
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
if (days === 0) return 'Today'
if (days === 1) return '1 day'
if (days < 30) return `${days} days`
if (days < 365) return `${Math.floor(days / 30)} months`
return `${Math.floor(days / 365)} years`
}
/** /**
* Close database connection * Close database connection
*/ */

View file

@ -27,13 +27,13 @@ function requireAuth(req, res, next) {
} }
// User is not authenticated // User is not authenticated
debug('Unauthenticated request, redirecting to login') debug('Unauthenticated request, redirecting to auth page')
// Store the original URL to redirect back after login // Store the original URL to redirect back after login
req.session.redirectAfterLogin = req.originalUrl req.session.redirectAfterLogin = req.originalUrl
// Redirect to login // Redirect to auth page
return res.redirect('/login') return res.redirect('/auth')
} }
/** /**

View file

@ -1,13 +1,20 @@
function checkLockAccess(req, res, next) { function checkLockAccess(req, res, next) {
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const address = req.params.address const address = req.params.address
const userId = req.session ? .userId
const isAuthenticated = req.session ? .isAuthenticated
if (!address || !inboxLock) { if (!address || !inboxLock) {
return next() return next()
} }
const isLocked = inboxLock.isLocked(address) const isLocked = inboxLock.isLocked(address)
const hasAccess = req.session && req.session.lockedInbox === address.toLowerCase()
// For authenticated users, check database ownership
// Also allow session-based access for immediate unlock after locking
const hasAccess = isAuthenticated && userId ?
(inboxLock.isLockedByUser(address, userId) || req.session.lockedInbox === address.toLowerCase()) :
(req.session ? .lockedInbox === address.toLowerCase())
// Block access to locked inbox without proper authentication // Block access to locked inbox without proper authentication
if (isLocked && !hasAccess) { if (isLocked && !hasAccess) {
@ -19,20 +26,19 @@ function checkLockAccess(req, res, next) {
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(), purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
address: address, address: address,
count: count, count: count,
message: 'This inbox is locked. Please unlock it to access.', 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,
showUnlockButton: true, currentUser: req.session ? .username,
unlockError: unlockError, authEnabled: req.app.get('config').user.authEnabled
redirectTo: req.originalUrl
}) })
} }
// Update last access if they have access // Update last access if they have access and are authenticated
if (isLocked && hasAccess) { if (isLocked && hasAccess && isAuthenticated && userId) {
inboxLock.updateAccess(address) inboxLock.updateAccess(userId, address)
} }
next() next()
} }
module.exports = { checkLockAccess } module.exports = { checkLockAccess }

View file

@ -114,10 +114,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeLock = document.getElementById('closeLock'); const closeLock = document.getElementById('closeLock');
const lockForm = document.querySelector('#lockModal form'); const lockForm = document.querySelector('#lockModal form');
const unlockModal = document.getElementById('unlockModal');
const unlockBtn = document.getElementById('unlockBtn');
const closeUnlock = document.getElementById('closeUnlock');
const removeLockModal = document.getElementById('removeLockModal'); const removeLockModal = document.getElementById('removeLockModal');
const removeLockBtn = document.getElementById('removeLockBtn'); const removeLockBtn = document.getElementById('removeLockBtn');
const closeRemoveLock = document.getElementById('closeRemoveLock'); const closeRemoveLock = document.getElementById('closeRemoveLock');
@ -136,35 +132,7 @@ document.addEventListener('DOMContentLoaded', () => {
closeLock.onclick = function() { closeModal(lockModal); }; closeLock.onclick = function() { closeModal(lockModal); };
} }
if (lockForm) { // Lock form no longer needs password validation - authentication-based locking
lockForm.addEventListener('submit', function(e) {
const pwElement = document.getElementById('lockPassword');
const cfElement = document.getElementById('lockConfirm');
const pw = pwElement ? pwElement.value : '';
const cf = cfElement ? cfElement.value : '';
const err = document.getElementById('lockErrorInline');
const serverErr = document.getElementById('lockServerError');
if (serverErr) serverErr.style.display = 'none';
if (pw !== cf) {
e.preventDefault();
if (err) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
}
return;
}
if (pw.length < 8) {
e.preventDefault();
if (err) {
err.textContent = 'Password must be at least 8 characters.';
err.style.display = 'block';
}
return;
}
if (err) err.style.display = 'none';
});
}
if (lockModal) { if (lockModal) {
const lockErrorValue = (lockModal.dataset.lockError || '').trim(); const lockErrorValue = (lockModal.dataset.lockError || '').trim();
@ -176,8 +144,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (err) { if (err) {
if (lockErrorValue === 'locking_disabled_for_example') { if (lockErrorValue === 'locking_disabled_for_example') {
err.textContent = 'Locking is disabled for the example inbox.'; err.textContent = 'Locking is disabled for the example inbox.';
} else if (lockErrorValue === 'invalid_password') { } else if (lockErrorValue === 'max_locked_inboxes') {
err.textContent = 'Please provide a valid password.'; err.textContent = 'You have reached the maximum of 5 locked inboxes.';
} else if (lockErrorValue === 'already_locked') {
err.textContent = 'This inbox is already locked by another user.';
} else if (lockErrorValue === 'server_error') { } else if (lockErrorValue === 'server_error') {
err.textContent = 'A server error occurred. Please try again.'; err.textContent = 'A server error occurred. Please try again.';
} else if (lockErrorValue === 'remove_failed') { } else if (lockErrorValue === 'remove_failed') {
@ -190,20 +160,6 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
if (unlockBtn) {
unlockBtn.onclick = function(e) {
e.preventDefault();
openModal(unlockModal);
};
}
if (closeUnlock) {
closeUnlock.onclick = function() { closeModal(unlockModal); };
}
if (unlockModal) {
const unlockErrorValue = (unlockModal.dataset.unlockError || '').trim();
if (unlockErrorValue) { openModal(unlockModal); }
}
if (removeLockBtn) { if (removeLockBtn) {
removeLockBtn.onclick = function(e) { removeLockBtn.onclick = function(e) {
e.preventDefault(); e.preventDefault();
@ -219,7 +175,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.onclick = function(e) { window.onclick = function(e) {
if (e.target === lockModal) closeModal(lockModal); if (e.target === lockModal) closeModal(lockModal);
if (e.target === unlockModal) closeModal(unlockModal);
if (e.target === removeLockModal) closeModal(removeLockModal); if (e.target === removeLockModal) closeModal(removeLockModal);
}; };
} }

View file

@ -297,6 +297,122 @@ text-muted {
} }
/* Action Dropdowns */
.action-dropdown {
position: relative;
display: inline-block;
}
/* Invisible hover area to keep dropdown open */
.action-dropdown::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 0;
height: calc(100% + 8px);
pointer-events: none;
}
.action-dropdown:hover::after {
pointer-events: auto;
}
.dropdown-toggle {
height: 42px;
padding: 0px 24px;
border-radius: 15px;
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.3s ease;
border: 1px solid var(--overlay-white-15);
background: transparent;
color: var(--color-text-light);
cursor: pointer;
position: relative;
overflow: hidden;
}
.dropdown-toggle::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, var(--overlay-white-10), transparent);
transition: left 0.5s;
}
.dropdown-toggle:hover::before {
left: 100%;
}
.dropdown-toggle:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px var(--overlay-purple-40);
border-color: var(--overlay-purple-30);
color: var(--color-text-white);
}
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
background: var(--color-bg-dark);
border: 1px solid var(--overlay-purple-30);
border-radius: 12px;
box-shadow: 0 8px 25px var(--overlay-purple-40);
z-index: 1000;
overflow: hidden;
padding: 8px 0;
}
.action-dropdown:hover .dropdown-menu,
.action-dropdown:focus-within .dropdown-menu,
.dropdown-menu:hover {
display: block;
}
.dropdown-menu a {
display: block;
padding: 12px 20px;
color: var(--color-text-light);
text-decoration: none;
font-size: 1rem;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
border: none;
border-radius: 0;
height: auto;
transition: all 0.2s ease;
}
.dropdown-menu a::before {
display: none;
}
.dropdown-menu a:hover {
background: var(--overlay-purple-30);
color: var(--color-text-white);
transform: none;
box-shadow: none;
border: none;
}
.dropdown-menu a:not(:last-child) {
border-bottom: 1px solid var(--overlay-white-10);
}
/* Auth pages */ /* Auth pages */
.auth-container { .auth-container {
@ -366,7 +482,6 @@ text-muted {
background: var(--color-accent-purple); background: var(--color-accent-purple);
color: white; color: white;
border: none; border: none;
padding: 0.75rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@ -431,6 +546,95 @@ text-muted {
} }
/* Unified auth page (side-by-side login/register) */
.auth-unified-container {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem;
}
.auth-intro {
text-align: center;
margin-bottom: 3rem;
}
.auth-intro h1 {
margin-bottom: 0.5rem;
color: var(--color-accent-purple);
}
.auth-forms-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
}
@media (max-width: 768px) {
.auth-forms-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
}
.auth-card h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--color-accent-purple);
}
.auth-card-subtitle {
color: var(--color-text-gray);
font-size: 1.4rem;
margin-bottom: 2rem;
}
.auth-features-unified {
text-align: center;
padding: 2rem;
background: var(--color-bg-dark);
border: 1px solid var(--color-border-dark);
border-radius: 8px;
}
.auth-features-unified h3 {
margin-bottom: 1.5rem;
color: var(--color-accent-purple);
}
.features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
@media (max-width: 768px) {
.features-grid {
grid-template-columns: 1fr;
}
}
.feature-item {
padding: 1rem;
background: var(--color-bg-medium);
border-radius: 4px;
font-size: 1.4rem;
}
.guest-note {
color: var(--color-text-gray);
font-size: 1.3rem;
margin-top: 1.5rem;
}
.guest-note a {
color: var(--color-accent-purple);
text-decoration: underline;
}
/* Verification success page */ /* Verification success page */
.verification-success-container { .verification-success-container {
@ -522,6 +726,237 @@ text-muted {
} }
/* Account dashboard page */
.account-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.account-subtitle {
color: var(--color-text-gray);
margin-bottom: 2rem;
}
.account-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.account-card {
background: var(--color-bg-dark);
border: 1px solid var(--color-border-dark);
border-radius: 8px;
padding: 2rem;
}
.account-card h2 {
color: var(--color-accent-purple);
margin-bottom: 0.5rem;
font-size: 1.5rem;
}
.card-description {
color: var(--color-text-gray);
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.account-stats {
grid-column: 1 / -1;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: var(--overlay-purple-08);
border-radius: 8px;
border: 1px solid var(--overlay-purple-15);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--color-accent-purple);
}
.stat-label {
color: var(--color-text-gray);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.email-list,
.inbox-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.email-item,
.inbox-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
margin-bottom: 0.5rem;
background: var(--overlay-white-03);
border: 1px solid var(--color-border-dark);
border-radius: 6px;
transition: background 0.2s;
}
.email-item:hover,
.inbox-item:hover {
background: var(--overlay-white-05);
}
.email-info,
.inbox-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.email-address {
font-weight: 600;
color: var(--color-text-light);
font-family: monospace;
}
.inbox-address {
font-weight: 600;
color: var(--color-accent-purple);
text-decoration: none;
font-family: monospace;
}
.inbox-address:hover {
text-decoration: underline;
}
.email-meta,
.inbox-meta {
font-size: 0.85rem;
color: var(--color-text-gray);
}
.inline-form {
display: inline;
}
.button-small {
padding: 0rem 1rem;
font-size: 0.85rem;
}
.button-danger {
background: var(--color-danger);
color: white;
border: none;
}
.button-danger:hover {
background: #c0392b;
}
.empty-state {
color: var(--color-text-gray);
font-style: italic;
text-align: center;
padding: 2rem;
}
.hint {
color: var(--color-text-gray);
font-size: 0.9rem;
margin-top: 1rem;
text-align: center;
}
.limit-reached {
color: var(--color-warning);
font-weight: 600;
text-align: center;
margin-top: 1rem;
}
@media (max-width: 768px) {
.account-grid {
grid-template-columns: 1fr;
}
.email-item,
.inbox-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.inline-form {
width: 100%;
}
.button-small {
width: 100%;
}
}
/* Forward modal auth prompts */
.auth-required {
background: var(--overlay-warning-10);
border-left: 3px solid var(--color-warning);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.auth-prompt {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.auth-prompt .button {
flex: 1;
text-align: center;
}
.form-hint {
display: block;
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.form-hint a {
color: var(--color-accent-purple);
text-decoration: none;
}
.form-hint a:hover {
text-decoration: underline;
}
.modal-info {
color: var(--color-text-gray);
font-size: 0.9rem;
text-align: center;
margin: 0.5rem 0;
}
/* Reset apple form styles */ /* Reset apple form styles */
input, input,
@ -1105,7 +1540,7 @@ label {
.attachment-link { .attachment-link {
color: var(--color-accent-purple-light); color: var(--color-accent-purple-light);
padding: 12px 16px; padding: 0px 16px;
background: var(--overlay-purple-10); background: var(--overlay-purple-10);
border: 1px solid var(--overlay-purple-20); border: 1px solid var(--overlay-purple-20);
transition: all 0.3s ease; transition: all 0.3s ease;
@ -1311,7 +1746,7 @@ label {
} }
.raw-tab-button { .raw-tab-button {
padding: 8px 14px; padding: 0px 14px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--overlay-white-12); border: 1px solid var(--overlay-white-12);
background: var(--overlay-white-05); background: var(--overlay-white-05);

View file

@ -0,0 +1,228 @@
// Account management routes for registered users
const express = require('express')
const router = express.Router()
const { requireAuth } = require('../middleware/auth')
const { body, validationResult } = require('express-validator')
// GET /account - Account dashboard
router.get('/account', requireAuth, async(req, res) => {
try {
const userRepository = req.app.get('userRepository')
const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService')
const Helper = require('../../../application/helper')
const helper = new Helper()
// Get user's verified forwarding emails
const forwardEmails = userRepository.getForwardEmails(req.session.userId)
// Get user's locked inboxes (if locking is available)
let lockedInboxes = []
if (inboxLock) {
lockedInboxes = inboxLock.getUserLockedInboxes(req.session.userId)
}
// Get user stats
const config = req.app.get('config')
const stats = userRepository.getUserStats(req.session.userId, config.user)
// Get mail count 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()
res.render('account', {
title: 'Account Dashboard',
username: req.session.username,
forwardEmails,
lockedInboxes,
stats,
branding: config.http.branding,
purgeTime: purgeTime,
totalcount: totalcount,
successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError
})
// Clear flash messages
delete req.session.accountSuccess
delete req.session.accountError
} catch (error) {
console.error('Account page error:', error)
res.status(500).render('error', {
message: 'Failed to load account page',
error: error
})
}
})
// POST /account/forward-email/add - Add forwarding email (triggers verification)
router.post('/account/forward-email/add',
requireAuth, [
body('email').isEmail().normalizeEmail().withMessage('Invalid email address')
],
async(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
try {
const userRepository = req.app.get('userRepository')
const smtpService = req.app.get('smtpService')
const verificationStore = req.app.get('verificationStore')
const config = req.app.get('config')
const crypto = require('crypto')
const { email } = req.body
// Check if already verified
if (userRepository.hasForwardEmail(req.session.userId, email)) {
req.session.accountError = 'This email is already verified on your account'
return res.redirect('/account')
}
// Check limit
const emailCount = userRepository.getForwardEmailCount(req.session.userId)
if (emailCount >= config.user.maxForwardEmails) {
req.session.accountError = `Maximum ${config.user.maxForwardEmails} forwarding emails allowed`
return res.redirect('/account')
}
// Generate verification token
const token = crypto.randomBytes(32).toString('hex')
verificationStore.createVerification(token, email, {
userId: req.session.userId
})
// Send verification email
const baseUrl = config.http.baseUrl || 'http://localhost:3000'
const branding = config.http.branding[0] || '48hr.email'
await smtpService.sendVerificationEmail(
email,
token,
baseUrl,
branding,
'/account/verify'
)
req.session.accountSuccess = `Verification email sent to ${email}. Check your inbox!`
res.redirect('/account')
} catch (error) {
console.error('Add forward email error:', error)
req.session.accountError = 'Failed to send verification email. Please try again.'
res.redirect('/account')
}
}
)
// GET /account/verify - Verify forwarding email
router.get('/account/verify', requireAuth, async(req, res) => {
const { token } = req.query
if (!token) {
req.session.accountError = 'Invalid verification link'
return res.redirect('/account')
}
try {
const verificationStore = req.app.get('verificationStore')
const userRepository = req.app.get('userRepository')
const verification = verificationStore.verifyToken(token)
if (!verification) {
req.session.accountError = 'Verification link expired or invalid'
return res.redirect('/account')
}
// Check if token belongs to this user
if (verification.metadata.userId !== req.session.userId) {
req.session.accountError = 'This verification link belongs to another account'
return res.redirect('/account')
}
// Add email to user's verified emails
userRepository.addForwardEmail(req.session.userId, verification.destinationEmail)
req.session.accountSuccess = `Successfully verified ${verification.destinationEmail}!`
res.redirect('/account')
} catch (error) {
console.error('Email verification error:', error)
req.session.accountError = 'Failed to verify email. Please try again.'
res.redirect('/account')
}
})
// POST /account/forward-email/remove - Remove forwarding email
router.post('/account/forward-email/remove',
requireAuth, [
body('email').isEmail().normalizeEmail().withMessage('Invalid email address')
],
async(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
try {
const userRepository = req.app.get('userRepository')
const { email } = req.body
userRepository.removeForwardEmail(req.session.userId, email)
req.session.accountSuccess = `Removed ${email} from your account`
res.redirect('/account')
} catch (error) {
console.error('Remove forward email error:', error)
req.session.accountError = 'Failed to remove email. Please try again.'
res.redirect('/account')
}
}
)
// POST /account/locked-inbox/release - Release a locked inbox
router.post('/account/locked-inbox/release',
requireAuth, [
body('address').notEmpty().withMessage('Inbox address is required')
],
async(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
try {
const inboxLock = req.app.get('inboxLock')
const { address } = req.body
if (!inboxLock) {
req.session.accountError = 'Inbox locking is not available'
return res.redirect('/account')
}
// Check if user owns this locked inbox
if (!inboxLock.isLockedByUser(address, req.session.userId)) {
req.session.accountError = 'You do not own this locked inbox'
return res.redirect('/account')
}
// Release the lock
inboxLock.release(req.session.userId, address)
req.session.accountSuccess = `Released lock on ${address}`
res.redirect('/account')
} catch (error) {
console.error('Release inbox error:', error)
req.session.accountError = 'Failed to release inbox. Please try again.'
res.redirect('/account')
}
}
)
module.exports = router

View file

@ -74,8 +74,8 @@ const loginRateLimiter = (req, res, next) => {
next() next()
} }
// GET /register - Show registration form // GET /auth - Show unified auth page (login or register)
router.get('/register', redirectIfAuthenticated, (req, res) => { router.get('/auth', redirectIfAuthenticated, (req, res) => {
const config = req.app.get('config') const config = req.app.get('config')
const errorMessage = req.session.errorMessage const errorMessage = req.session.errorMessage
const successMessage = req.session.successMessage const successMessage = req.session.successMessage
@ -84,8 +84,8 @@ router.get('/register', redirectIfAuthenticated, (req, res) => {
delete req.session.errorMessage delete req.session.errorMessage
delete req.session.successMessage delete req.session.successMessage
res.render('register', { res.render('login-auth', {
title: `Register | ${config.http.branding[0]}`, title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding, branding: config.http.branding,
errorMessage, errorMessage,
successMessage successMessage
@ -106,7 +106,7 @@ router.post('/register',
const firstError = errors.array()[0].msg const firstError = errors.array()[0].msg
debug(`Registration validation failed: ${firstError}`) debug(`Registration validation failed: ${firstError}`)
req.session.errorMessage = firstError req.session.errorMessage = firstError
return res.redirect('/register') return res.redirect('/auth')
} }
const { username, password, confirmPassword } = req.body const { username, password, confirmPassword } = req.body
@ -115,7 +115,7 @@ router.post('/register',
if (password !== confirmPassword) { if (password !== confirmPassword) {
debug('Registration failed: Passwords do not match') debug('Registration failed: Passwords do not match')
req.session.errorMessage = 'Passwords do not match' req.session.errorMessage = 'Passwords do not match'
return res.redirect('/register') return res.redirect('/auth')
} }
const authService = req.app.get('authService') const authService = req.app.get('authService')
@ -124,39 +124,21 @@ router.post('/register',
if (result.success) { if (result.success) {
debug(`User registered successfully: ${username}`) debug(`User registered successfully: ${username}`)
req.session.successMessage = 'Registration successful! Please log in.' req.session.successMessage = 'Registration successful! Please log in.'
return res.redirect('/login') return res.redirect('/auth')
} else { } else {
debug(`Registration failed: ${result.error}`) debug(`Registration failed: ${result.error}`)
req.session.errorMessage = result.error req.session.errorMessage = result.error
return res.redirect('/register') return res.redirect('/auth')
} }
} catch (error) { } catch (error) {
debug(`Registration error: ${error.message}`) debug(`Registration error: ${error.message}`)
console.error('Error during registration', error) console.error('Error during registration', error)
req.session.errorMessage = 'An unexpected error occurred. Please try again.' req.session.errorMessage = 'An unexpected error occurred. Please try again.'
res.redirect('/register') res.redirect('/auth')
} }
} }
) )
// GET /login - Show login form
router.get('/login', redirectIfAuthenticated, (req, res) => {
const config = req.app.get('config')
const errorMessage = req.session.errorMessage
const successMessage = req.session.successMessage
// Clear messages after reading
delete req.session.errorMessage
delete req.session.successMessage
res.render('login-auth', {
title: `Login | ${config.http.branding[0]}`,
branding: config.http.branding,
errorMessage,
successMessage
})
})
// POST /login - Process login // POST /login - Process login
router.post('/login', router.post('/login',
redirectIfAuthenticated, redirectIfAuthenticated,
@ -170,7 +152,7 @@ router.post('/login',
const firstError = errors.array()[0].msg const firstError = errors.array()[0].msg
debug(`Login validation failed: ${firstError}`) debug(`Login validation failed: ${firstError}`)
req.session.errorMessage = firstError req.session.errorMessage = firstError
return res.redirect('/login') return res.redirect('/auth')
} }
const { username, password } = req.body const { username, password } = req.body
@ -187,7 +169,7 @@ router.post('/login',
if (err) { if (err) {
debug(`Session regeneration error: ${err.message}`) debug(`Session regeneration error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.' req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/login') return res.redirect('/auth')
} }
// Set session data // Set session data
@ -200,7 +182,7 @@ router.post('/login',
if (err) { if (err) {
debug(`Session save error: ${err.message}`) debug(`Session save error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.' req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/login') return res.redirect('/auth')
} }
debug(`Session created for user: ${username}`) debug(`Session created for user: ${username}`)
@ -210,13 +192,13 @@ router.post('/login',
} else { } else {
debug(`Login failed: ${result.error}`) debug(`Login failed: ${result.error}`)
req.session.errorMessage = result.error req.session.errorMessage = result.error
return res.redirect('/login') return res.redirect('/auth')
} }
} catch (error) { } catch (error) {
debug(`Login error: ${error.message}`) debug(`Login error: ${error.message}`)
console.error('Error during login', error) console.error('Error during login', error)
req.session.errorMessage = 'An unexpected error occurred. Please try again.' req.session.errorMessage = 'An unexpected error occurred. Please try again.'
res.redirect('/login') res.redirect('/auth')
} }
} }
) )

View file

@ -9,6 +9,7 @@ const CryptoDetector = require('../../../application/crypto-detector')
const helper = new(Helper) const helper = new(Helper)
const cryptoDetector = new CryptoDetector() const cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock') const { checkLockAccess } = require('../middleware/lock')
const { requireAuth, optionalAuth } = require('../middleware/auth')
const purgeTime = helper.purgeTimeElemetBuilder() const purgeTime = helper.purgeTimeElemetBuilder()
@ -97,7 +98,7 @@ const validateForwardRequest = [
}) })
] ]
router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLockAccess, async(req, res, next) => { router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optionalAuth, checkLockAccess, async(req, res, next) => {
try { try {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
if (!mailProcessingService) { if (!mailProcessingService) {
@ -109,8 +110,25 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
const largestUid = await req.app.locals.imapService.getLargestUid() const largestUid = await req.app.locals.imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid) const totalcount = helper.countElementBuilder(count, largestUid)
debug(`Rendering inbox with ${count} total mails`) debug(`Rendering inbox with ${count} total mails`)
// Check lock status
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address const userId = req.session ? .userId
const isAuthenticated = req.session ? .isAuthenticated
// Check if user has access (either owns the lock or has session access)
const hasAccess = isAuthenticated && userId && inboxLock ?
(inboxLock.isLockedByUser(req.params.address, userId) || req.session.lockedInbox === req.params.address) :
(req.session ? .lockedInbox === req.params.address)
// Get user's verified emails if logged in
let userForwardEmails = []
if (req.session && req.session.userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(req.session.userId)
}
}
// Pull any lock error from session and clear it after reading // Pull any lock error from session and clear it after reading
const lockError = req.session ? req.session.lockError : undefined const lockError = req.session ? req.session.lockError : undefined
@ -138,6 +156,8 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
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,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked, isLocked: isLocked,
hasAccess: hasAccess, hasAccess: hasAccess,
unlockError: unlockErrorSession, unlockError: unlockErrorSession,
@ -163,6 +183,7 @@ router.get(
'^/:address/:uid([0-9]+)', '^/:address/:uid([0-9]+)',
sanitizeAddress, sanitizeAddress,
validateDomain, validateDomain,
optionalAuth,
checkLockAccess, checkLockAccess,
async(req, res, next) => { async(req, res, next) => {
try { try {
@ -190,7 +211,22 @@ router.get(
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address const userId = req.session ? .userId
const isAuthenticated = req.session ? .isAuthenticated
// Check if user has access (either owns the lock or has session access)
const hasAccess = isAuthenticated && userId && inboxLock ?
(inboxLock.isLockedByUser(req.params.address, userId) || req.session.lockedInbox === req.params.address) :
(req.session ? .lockedInbox === req.params.address)
// Get user's verified emails if logged in
let userForwardEmails = []
if (req.session && req.session.userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(req.session.userId)
}
}
// Pull error message from session and clear it // Pull error message from session and clear it
const errorMessage = req.session ? req.session.errorMessage : undefined const errorMessage = req.session ? req.session.errorMessage : undefined
@ -217,6 +253,8 @@ router.get(
uid: req.params.uid, uid: req.params.uid,
branding: config.http.branding, branding: config.http.branding,
authEnabled: config.user.authEnabled, authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked, isLocked: isLocked,
hasAccess: hasAccess, hasAccess: hasAccess,
errorMessage: errorMessage, errorMessage: errorMessage,
@ -418,9 +456,10 @@ router.get(
} }
) )
// POST route for forwarding a single email // POST route for forwarding a single email (requires authentication)
router.post( router.post(
'^/:address/:uid/forward', '^/:address/:uid/forward',
requireAuth,
forwardLimiter, forwardLimiter,
validateDomain, validateDomain,
checkLockAccess, checkLockAccess,
@ -436,51 +475,36 @@ router.post(
} }
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const userRepository = req.app.get('userRepository')
const { destinationEmail } = req.body const { destinationEmail } = req.body
const uid = parseInt(req.params.uid, 10) const uid = parseInt(req.params.uid, 10)
// Check if destination email is verified via signed cookie // Check if destination email is in user's verified emails
const verifiedEmail = req.signedCookies.verified_email const userEmails = userRepository.getForwardEmails(req.session.userId)
const isVerified = userEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase())
if (verifiedEmail && verifiedEmail.toLowerCase() === destinationEmail.toLowerCase()) { if (!isVerified) {
// Email is verified, proceed with forwarding debug(`Email ${destinationEmail} not in user's verified emails`)
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (verified)`) req.session.errorMessage = 'Please select a verified email address from your account'
return res.redirect(`/inbox/${req.params.address}/${req.params.uid}`)
}
const result = await mailProcessingService.forwardEmail( // Email is verified, proceed with forwarding
req.params.address, debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (user verified)`)
uid,
destinationEmail
)
if (result.success) { const result = await mailProcessingService.forwardEmail(
debug(`Email ${uid} forwarded successfully to ${destinationEmail}`) req.params.address,
return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`) uid,
} else { destinationEmail
debug(`Failed to forward email ${uid}: ${result.error}`) )
req.session.errorMessage = result.error
return res.redirect(`/inbox/${req.params.address}/${uid}`) if (result.success) {
} debug(`Email ${uid} forwarded successfully to ${destinationEmail}`)
return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`)
} else { } else {
// Email not verified, initiate verification flow debug(`Failed to forward email ${uid}: ${result.error}`)
debug(`Email ${destinationEmail} not verified, initiating verification`) req.session.errorMessage = result.error
return res.redirect(`/inbox/${req.params.address}/${uid}`)
const verificationResult = await mailProcessingService.initiateForwardVerification(
req.params.address,
destinationEmail, [uid]
)
if (verificationResult.success) {
debug(`Verification email sent to ${destinationEmail}`)
return res.redirect(`/inbox/${req.params.address}/${uid}?verificationSent=true&email=${encodeURIComponent(destinationEmail)}`)
} else if (verificationResult.cooldownSeconds) {
debug(`Verification rate limited for ${destinationEmail}`)
req.session.errorMessage = verificationResult.error
return res.redirect(`/inbox/${req.params.address}/${uid}`)
} else {
debug(`Failed to send verification email: ${verificationResult.error}`)
req.session.errorMessage = verificationResult.error || 'Failed to send verification email'
return res.redirect(`/inbox/${req.params.address}/${uid}`)
}
} }
} catch (error) { } catch (error) {
debug(`Error forwarding email ${req.params.uid}: ${error.message}`) debug(`Error forwarding email ${req.params.uid}: ${error.message}`)
@ -491,9 +515,10 @@ router.post(
} }
) )
// POST route for forwarding all emails in an inbox // POST route for forwarding all emails in an inbox (requires authentication)
router.post( router.post(
'^/:address/forward-all', '^/:address/forward-all',
requireAuth,
forwardLimiter, forwardLimiter,
validateDomain, validateDomain,
checkLockAccess, checkLockAccess,
@ -509,40 +534,21 @@ router.post(
} }
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const userRepository = req.app.get('userRepository')
const { destinationEmail } = req.body const { destinationEmail } = req.body
// Check if destination email is verified via signed cookie // Check if destination email is in user's verified emails
const verifiedEmail = req.signedCookies.verified_email const userEmails = userRepository.getForwardEmails(req.session.userId)
const isVerified = userEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase())
if (!verifiedEmail || verifiedEmail.toLowerCase() !== destinationEmail.toLowerCase()) { if (!isVerified) {
// Email not verified, initiate verification flow debug(`Email ${destinationEmail} not in user's verified emails`)
debug(`Email ${destinationEmail} not verified, initiating verification for forward-all`) req.session.errorMessage = 'Please select a verified email address from your account'
return res.redirect(`/inbox/${req.params.address}`)
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
const uids = mailSummaries.map(m => m.uid)
const verificationResult = await mailProcessingService.initiateForwardVerification(
req.params.address,
destinationEmail,
uids
)
if (verificationResult.success) {
debug(`Verification email sent to ${destinationEmail}`)
return res.redirect(`/inbox/${req.params.address}?verificationSent=true&email=${encodeURIComponent(destinationEmail)}`)
} else if (verificationResult.cooldownSeconds) {
debug(`Verification rate limited for ${destinationEmail}`)
req.session.errorMessage = verificationResult.error
return res.redirect(`/inbox/${req.params.address}`)
} else {
debug(`Failed to send verification email: ${verificationResult.error}`)
req.session.errorMessage = verificationResult.error || 'Failed to send verification email'
return res.redirect(`/inbox/${req.params.address}`)
}
} }
// Email is verified, proceed with bulk forwarding // Email is verified, proceed with bulk forwarding
debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail} (verified)`) debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail} (user verified)`)
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address) const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)

View file

@ -1,13 +1,15 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const debug = require('debug')('48hr-email:lock') const debug = require('debug')('48hr-email:lock')
const { requireAuth } = require('../middleware/auth')
router.post('/lock', async(req, res) => { router.post('/lock', requireAuth, async(req, res) => {
const { address, password } = req.body const { address } = req.body
debug(`Lock attempt for inbox: ${address}`); const userId = req.session.userId
debug(`Lock attempt for inbox: ${address} by user ${userId}`)
if (!address || !password || password.length < 8) { if (!address) {
debug(`Lock error for ${address}: invalid input`); debug(`Lock error for ${address}: missing address`)
if (req.session) req.session.lockError = 'invalid' if (req.session) req.session.lockError = 'invalid'
return res.redirect(`/inbox/${address}`) return res.redirect(`/inbox/${address}`)
} }
@ -17,58 +19,88 @@ router.post('/lock', async(req, res) => {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
const config = req.app.get('config') const config = req.app.get('config')
// Prevent locking the example inbox; allow UI but block DB insert if (!inboxLock) {
debug('Lock error: inboxLock service not available')
if (req.session) req.session.lockError = 'service_unavailable'
return res.redirect(`/inbox/${address}`)
}
// Prevent locking the example inbox
if (config && config.email && config.email.examples && config.email.examples.account && address.toLowerCase() === config.email.examples.account.toLowerCase()) { if (config && config.email && config.email.examples && config.email.examples.account && address.toLowerCase() === config.email.examples.account.toLowerCase()) {
debug(`Lock error for ${address}: locking disabled for example inbox`); debug(`Lock error for ${address}: locking disabled for example inbox`)
if (req.session) req.session.lockError = 'locking_disabled_for_example' if (req.session) req.session.lockError = 'locking_disabled_for_example'
return res.redirect(`/inbox/${address}`) return res.redirect(`/inbox/${address}`)
} }
await inboxLock.lock(address, password) // Check if user can lock more inboxes (5 max)
debug(`Inbox locked: ${address}`); if (!inboxLock.canLockMore(userId)) {
debug(`Lock error for ${address}: user ${userId} has reached 5-inbox limit`)
if (req.session) req.session.lockError = 'max_locked_inboxes'
return res.redirect(`/inbox/${address}`)
}
await inboxLock.lock(userId, address)
debug(`Inbox locked: ${address} by user ${userId}`)
// Clear cache for this inbox // Clear cache for this inbox
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug(`Clearing lock cache for: ${address}`); debug(`Clearing lock cache for: ${address}`)
mailProcessingService.cachedFetchFullMail.clear() mailProcessingService.cachedFetchFullMail.clear()
} }
// Store in session for immediate access
req.session.lockedInbox = address req.session.lockedInbox = address
res.redirect(`/inbox/${address}`) res.redirect(`/inbox/${address}`)
} catch (error) { } catch (error) {
debug(`Lock error for ${address}: ${error.message}`); debug(`Lock error for ${address}: ${error.message}`)
console.error('Lock error:', error) console.error('Lock error:', error)
if (req.session) req.session.lockError = 'server_error' if (req.session) {
if (error.message.includes('already locked')) {
req.session.lockError = 'already_locked'
} else if (error.message.includes('maximum')) {
req.session.lockError = 'max_locked_inboxes'
} else {
req.session.lockError = 'server_error'
}
}
res.redirect(`/inbox/${address}`) res.redirect(`/inbox/${address}`)
} }
}) })
router.post('/unlock', async(req, res) => { router.post('/unlock', requireAuth, async(req, res) => {
const { address, password, redirectTo } = req.body const { address, redirectTo } = req.body
const userId = req.session.userId
const destination = redirectTo && redirectTo.startsWith('/') ? redirectTo : `/inbox/${address}` const destination = redirectTo && redirectTo.startsWith('/') ? redirectTo : `/inbox/${address}`
debug(`Unlock attempt for inbox: ${address}`); debug(`Unlock attempt for inbox: ${address} by user ${userId}`)
if (!address || !password) { if (!address) {
debug(`Unlock error for ${address}: missing fields`); debug(`Unlock error for ${address}: missing address`)
if (req.session) req.session.unlockError = 'missing_fields' if (req.session) req.session.unlockError = 'missing_fields'
return res.redirect(destination) return res.redirect(destination)
} }
try { try {
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const inbox = await inboxLock.unlock(address, password)
if (!inbox) { if (!inboxLock) {
debug(`Unlock error for ${address}: invalid password`); debug('Unlock error: inboxLock service not available')
if (req.session) req.session.unlockError = 'invalid_password' if (req.session) req.session.unlockError = 'service_unavailable'
return res.redirect(destination) return res.redirect(destination)
} }
debug(`Inbox unlocked: ${address}`); const inbox = await inboxLock.unlock(userId, address)
if (!inbox) {
debug(`Unlock error for ${address}: not owned by user ${userId}`)
if (req.session) req.session.unlockError = 'not_your_lock'
return res.redirect(destination)
}
debug(`Inbox ${address} unlocked by user ${userId}`)
req.session.lockedInbox = address req.session.lockedInbox = address
res.redirect(destination) res.redirect(destination)
} catch (error) { } catch (error) {
debug(`Unlock error for ${address}: ${error.message}`); debug(`Unlock error for ${address}: ${error.message}`)
console.error('Unlock error:', error) console.error('Unlock error:', error)
if (req.session) req.session.unlockError = 'server_error' if (req.session) req.session.unlockError = 'server_error'
res.redirect(destination) res.redirect(destination)
@ -80,55 +112,62 @@ router.get('/logout', (req, res) => {
// Clear cache before logout // Clear cache before logout
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug('Clearing lock cache for logout'); debug('Clearing lock cache for logout')
mailProcessingService.cachedFetchFullMail.clear() mailProcessingService.cachedFetchFullMail.clear()
} }
debug('Lock session destroyed (logout)'); debug('Clearing lockedInbox from session (lock logout)')
req.session.destroy() delete req.session.lockedInbox
res.redirect('/') res.redirect('/')
}) })
router.post('/remove', async(req, res) => { router.post('/remove', requireAuth, async(req, res) => {
const { address } = req.body const { address } = req.body
debug(`Remove lock attempt for inbox: ${address}`); const userId = req.session.userId
debug(`Remove lock attempt for inbox: ${address} by user ${userId}`)
if (!address) { if (!address) {
debug('Remove lock error: missing address'); debug('Remove lock error: missing address')
return res.redirect('/') return res.redirect('/')
} }
// Check if user has access to this locked inbox
const hasAccess = req.session && req.session.lockedInbox === address.toLowerCase()
debug(`Lock middleware: ${address} - hasAccess: ${hasAccess}`);
if (!hasAccess) {
debug(`Remove lock error: no access for ${address}`);
return res.redirect(`/inbox/${address}`)
}
try { try {
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
await inboxLock.release(address) if (!inboxLock) {
debug(`Lock removed for inbox: ${address}`); debug('Remove lock error: inboxLock service not available')
return res.redirect(`/inbox/${address}`)
}
// Verify user owns this lock
if (!inboxLock.isLockedByUser(address, userId)) {
debug(`Remove lock error: inbox ${address} not owned by user ${userId}`)
if (req.session) req.session.lockError = 'not_your_lock'
return res.redirect(`/inbox/${address}`)
}
await inboxLock.release(userId, address)
debug(`Lock removed for inbox: ${address} by user ${userId}`)
// Clear cache when removing lock // Clear cache when removing lock
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug(`Clearing lock cache for: ${address}`); debug(`Clearing lock cache for: ${address}`)
mailProcessingService.cachedFetchFullMail.clear() mailProcessingService.cachedFetchFullMail.clear()
} }
debug('Lock session destroyed (remove)'); // Clear from session
req.session.destroy() if (req.session.lockedInbox === address.toLowerCase()) {
delete req.session.lockedInbox
}
res.redirect(`/inbox/${address}`) res.redirect(`/inbox/${address}`)
} catch (error) { } catch (error) {
debug(`Remove lock error for ${address}: ${error.message}`); debug(`Remove lock error for ${address}: ${error.message}`)
console.error('Remove lock error:', error) console.error('Remove lock error:', error)
if (req.session) req.session.lockError = 'remove_failed' if (req.session) req.session.lockError = 'remove_failed'
res.redirect(`/inbox/${address}`) res.redirect(`/inbox/${address}`)
} }
}) })
module.exports = router module.exports = router

View file

@ -0,0 +1,170 @@
{% extends 'layout.twig' %}
{% block header %}
<div class="action-links">
<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 id="account" class="account-container">
<h1>Account Dashboard</h1>
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
{% endif %}
<div class="account-grid">
<!-- Account Stats -->
<div class="account-card account-stats">
<h2>Account Overview</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ stats.forwardEmailsCount }}/{{ stats.maxForwardEmails }}</div>
<div class="stat-label">Forward Emails</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.lockedInboxesCount }}/{{ stats.maxLockedInboxes }}</div>
<div class="stat-label">Locked Inboxes</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.accountAge }}</div>
<div class="stat-label">Account Age</div>
</div>
</div>
</div>
<!-- Forwarding Emails Section -->
<div class="account-card">
<h2>Forwarding Emails</h2>
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
{% if forwardEmails|length > 0 %}
<ul class="email-list">
{% for email in forwardEmails %}
<li class="email-item">
<div class="email-info">
<span class="email-address">{{ email.email }}</span>
<span class="email-meta">Verified {{ email.verifiedAgo }}</span>
</div>
<form method="POST" action="/account/forward-email/remove" class="inline-form">
<input type="hidden" name="email" value="{{ email.email }}">
<button type="submit" class="button button-small button-danger" onclick="return confirm('Remove {{ email.email }}?')">
Remove
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No verified forwarding emails yet.</p>
{% endif %}
{% if stats.forwardEmailsCount < stats.maxForwardEmails %}
<button class="button button-primary" id="addEmailBtn">Add Email</button>
{% else %}
<p class="limit-reached">Maximum {{ stats.maxForwardEmails }} emails reached</p>
{% endif %}
</div>
<!-- Locked Inboxes Section -->
<div class="account-card">
<h2>Locked Inboxes</h2>
<p class="card-description">Manage your locked inboxes. These are protected by your account and only accessible when logged in.</p>
{% if lockedInboxes|length > 0 %}
<ul class="inbox-list">
{% for inbox in lockedInboxes %}
<li class="inbox-item">
<div class="inbox-info">
<a href="/inbox/{{ inbox.address }}" class="inbox-address">{{ inbox.address }}</a>
<span class="inbox-meta">Last accessed {{ inbox.lastAccessedAgo }}</span>
</div>
<form method="POST" action="/account/locked-inbox/release" class="inline-form">
<input type="hidden" name="address" value="{{ inbox.address }}">
<button type="submit" class="button button-small button-danger" onclick="return confirm('Release lock on {{ inbox.address }}?')">
Release
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No locked inboxes yet. Lock an inbox to protect it with your account.</p>
{% endif %}
{% if stats.lockedInboxesCount < stats.maxLockedInboxes %}
<p class="hint">You can lock up to {{ stats.maxLockedInboxes }} inboxes total.</p>
{% else %}
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
{% endif %}
</div>
</div>
</div>
<!-- Add Email Modal -->
<div id="addEmailModal" class="modal">
<div class="modal-content">
<span class="close" id="closeAddEmail">&times;</span>
<h3>Add Forwarding Email</h3>
<p class="modal-description">Enter an email address to verify. We'll send you a verification link.</p>
<form method="POST" action="/account/forward-email/add">
<fieldset>
<label for="forwardEmail">Email Address</label>
<input
type="email"
id="forwardEmail"
name="email"
placeholder="your-email@example.com"
required
class="modal-input"
>
<button type="submit" class="button-primary modal-button">Send Verification</button>
</fieldset>
</form>
</div>
</div>
<script>
// Add Email Modal
const addEmailBtn = document.getElementById('addEmailBtn');
const addEmailModal = document.getElementById('addEmailModal');
const closeAddEmail = document.getElementById('closeAddEmail');
if (addEmailBtn) {
addEmailBtn.onclick = function() {
addEmailModal.style.display = 'block';
}
}
if (closeAddEmail) {
closeAddEmail.onclick = function() {
addEmailModal.style.display = 'none';
}
}
window.onclick = function(event) {
if (event.target == addEmailModal) {
addEmailModal.style.display = 'none';
}
}
</script>
{% endblock %}

View file

@ -2,10 +2,10 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
{% if showUnlockButton %} {% if authEnabled and not currentUser %}
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a> <a href="/auth" aria-label="Login or Register">Account</a>
{% endif %} {% endif %}
<a href="/" aria-label="Return to home">Logout</a> <a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <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"> <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"/> <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"/>
@ -21,48 +21,4 @@
<h1>{{message}}</h1> <h1>{{message}}</h1>
<h2>{{error.status}}</h2> <h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre> <pre>{{error.stack}}</pre>
{% if showUnlockButton %}
<div id="unlockModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeUnlock">&times;</span>
<h3>Unlock Inbox</h3>
<p class="modal-description">Enter password to access this locked inbox.</p>
{% if unlockError %}
<p class="unlock-error">
{% if unlockError == 'invalid_password' %}
Invalid password. Please try again.
{% elseif unlockError == 'missing_fields' %}
Please provide a password.
{% else %}
An error occurred. Please try again.
{% endif %}
</p>
{% endif %}
<form method="POST" action="/lock/unlock">
<input type="hidden" name="address" value="{{ address }}">
<input type="hidden" name="redirectTo" value="{{ redirectTo|default(address) }}">
<fieldset>
<label for="unlockPassword" class="floating-label">Password</label>
<input type="password" id="unlockPassword" name="password" required class="modal-input">
<button type="submit" class="button-primary modal-button">Unlock</button>
</fieldset>
</form>
</div>
</div>
<script>
const modal = document.getElementById('unlockModal');
const btn = document.getElementById('unlockBtn');
const close = document.getElementById('closeUnlock');
if (btn) btn.onclick = (e) => { e.preventDefault(); modal.style.display = 'block'; };
if (close) close.onclick = () => modal.style.display = 'none';
window.onclick = (e) => { if (e.target == modal) modal.style.display = 'none'; };
// Auto-open modal if there's an unlock error
if ('{{ unlockError|default("") }}') {
modal.style.display = 'block';
}
</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -2,22 +2,41 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
{% if authEnabled %} {% if currentUser %}
{% if isLocked and hasAccess %} <!-- Inbox Dropdown (multiple actions when logged in) -->
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a> <div class="action-dropdown">
{% elseif isLocked %} <button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a> <div class="dropdown-menu">
{% else %} <a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
<a href="#" id="lockBtn" aria-label="Protect inbox with password">Protect Inbox</a> {% if authEnabled %}
{% if isLocked and hasAccess %}
<a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a>
{% elseif not isLocked %}
<a href="#" id="lockBtn" aria-label="Lock inbox to your account">Lock Inbox</a>
{% endif %}
{% endif %}
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
</div>
</div>
<!-- Account Dropdown (logged in) -->
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %}
{% else %}
<!-- Simple buttons when not logged in -->
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
{% if authEnabled and hasAccess %}
<a href="/lock/logout" aria-label="Logout">Logout</a>
{% else %}
<a href="/logout" aria-label="Logout">Logout</a>
{% endif %}
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <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"> <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"/> <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"/>
@ -93,21 +112,22 @@
<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">
<span class="close" id="closeLock">&times;</span> <span class="close" id="closeLock">&times;</span>
<h3>Protect Inbox</h3> <h3>Lock Inbox</h3>
<p class="modal-description">Password-protect this inbox. Locked emails won't be deleted. Protection active for {{ locktimer }}hrs after last login.</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="unlock-error">Locking is disabled for the example inbox.</p>
{% 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>
{% elseif error and error == 'already_locked' %}
<p id="lockServerError" class="unlock-error">This inbox is already locked by another user.</p>
{% elseif error and error == 'not_your_lock' %}
<p id="lockServerError" class="unlock-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="unlock-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>
<label for="lockPassword" class="floating-label">Password (min 8 characters)</label> <p>This inbox will be protected with your account. Only you will be able to access it while logged in.</p>
<input type="password" id="lockPassword" name="password" placeholder="Password" required minlength="8" class="modal-input">
<label for="lockConfirm" class="floating-label">Confirm Password</label>
<input type="password" id="lockConfirm" placeholder="Confirm" required minlength="8" class="modal-input">
<button type="submit" class="button-primary modal-button">Lock Inbox</button> <button type="submit" class="button-primary modal-button">Lock Inbox</button>
</fieldset> </fieldset>
</form> </form>
@ -116,45 +136,15 @@
{% endif %} {% endif %}
{% if authEnabled and isLocked and not hasAccess %}
<!-- Unlock Modal -->
<div id="unlockModal" class="modal" style="display: none;" data-unlock-error="{{ unlockError|default('') }}">
<div class="modal-content">
<span class="close" id="closeUnlock">&times;</span>
<h3>Unlock Inbox</h3>
<p class="modal-description">Enter password to access this locked inbox.</p>
{% if unlockError %}
<p class="unlock-error">
{% if unlockError == 'invalid_password' %}
Invalid password. Please try again.
{% elseif unlockError == 'missing_fields' %}
Please provide a password.
{% else %}
An error occurred. Please try again.
{% endif %}
</p>
{% endif %}
<form method="POST" action="/lock/unlock">
<input type="hidden" name="address" value="{{ address }}">
<input type="hidden" name="redirectTo" value="{{ redirectTo }}">
<fieldset>
<label for="unlockPassword" class="floating-label">Password</label>
<input type="password" id="unlockPassword" name="password" placeholder="Password" required class="modal-input">
<button type="submit" class="button-primary modal-button">Unlock</button>
</fieldset>
</form>
</div>
</div>
{% endif %}
{% 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">
<span class="close" id="closeRemoveLock">&times;</span> <span class="close" id="closeRemoveLock">&times;</span>
<h3>Remove Password Lock</h3> <h3>Remove Lock</h3>
<p class="modal-description">Are you sure you want to remove the password lock from this inbox? This cannot be undone.</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>
<form method="POST" action="/lock/remove"> <form method="POST" action="/lock/remove">
<input type="hidden" name="address" value="{{ address }}"> <input type="hidden" name="address" value="{{ address }}">
<fieldset> <fieldset>
@ -183,19 +173,38 @@
<div class="modal-content"> <div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span> <span class="close" id="closeForwardAll">&times;</span>
<h3>Forward All Emails</h3> <h3>Forward All Emails</h3>
<p class="modal-description">Enter the email address to forward all emails in this inbox to. Limited to 25 emails maximum.</p>
{% if mailSummaries|length > 0 %} {% if not currentUser %}
<p class="modal-info">You have {{ mailSummaries|length }} email(s) in this inbox.</p> <p class="modal-description auth-required">
<strong>Login required:</strong> You must be logged in to forward emails.
</p>
<div class="auth-prompt">
<a href="/auth?redirect={{ ('/inbox/' ~ address)|url_encode }}" class="button button-primary">Login or Register</a>
</div>
{% elseif userForwardEmails|length == 0 %}
<p class="modal-description">You don't have any verified forwarding emails yet.</p>
<p class="modal-description">Add a verified email address in your account settings to enable forwarding.</p>
<a href="/account" class="button button-primary">Go to Account Settings</a>
{% else %}
<p class="modal-description">Select a verified email address to forward all emails to. Limited to 25 emails maximum.</p>
{% if mailSummaries|length > 0 %}
<p class="modal-info">You have {{ mailSummaries|length }} email(s) in this inbox.</p>
{% endif %}
<p id="forwardAllError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/forward-all">
<fieldset>
<label for="forwardAllEmail" class="floating-label">Forward to</label>
<select id="forwardAllEmail" name="destinationEmail" required class="modal-input">
<option value="">Select an email...</option>
{% for email in userForwardEmails %}
<option value="{{ email.email }}">{{ email.email }}</option>
{% endfor %}
</select>
<small class="form-hint">Manage emails in <a href="/account">Account Settings</a></small>
<button type="submit" class="button-primary modal-button">Forward All</button>
</fieldset>
</form>
{% endif %} {% endif %}
<p id="forwardAllError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/forward-all">
<fieldset>
<label for="forwardAllEmail" class="floating-label">Destination Email</label>
<input type="email" id="forwardAllEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<button type="submit" class="button-primary modal-button">Forward All</button>
</fieldset>
</form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -2,8 +2,7 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
<a href="/" aria-label="Return to home">← Home</a> <a href="/" aria-label="Return to home">Home</a>
<a href="/register" aria-label="Register">Register</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <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"> <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"/> <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"/>
@ -16,70 +15,110 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="login-auth" class="auth-container"> <div id="auth-unified" class="auth-unified-container">
<div class="auth-card"> <div class="auth-intro">
<h1>Welcome Back</h1> <h1>Account Access</h1>
<p class="auth-subtitle">Login to access your account</p> <p class="auth-subtitle">Login to an existing account or create a new one</p>
{% if errorMessage %} {% if errorMessage %}
<div class="unlock-error"> <div class="unlock-error">{{ errorMessage }}</div>
{{ errorMessage }}
</div>
{% endif %} {% endif %}
{% if successMessage %} {% if successMessage %}
<div class="success-message"> <div class="success-message">{{ successMessage }}</div>
{{ successMessage }}
</div>
{% endif %} {% endif %}
</div>
<form method="POST" action="/login"> <div class="auth-forms-grid">
<fieldset> <!-- Login Form -->
<label for="username">Username</label> <div class="auth-card">
<input <h2>Login</h2>
type="text" <p class="auth-card-subtitle">Access your existing account</p>
id="username"
name="username" <form method="POST" action="/login">
placeholder="Enter your username" <fieldset>
required <label for="login-username">Username</label>
autocomplete="username" <input
> type="text"
id="login-username"
name="username"
placeholder="Your username"
required
autocomplete="username"
>
<label for="password">Password</label> <label for="login-password">Password</label>
<input <input
type="password" type="password"
id="password" id="login-password"
name="password" name="password"
placeholder="Enter your password" placeholder="Your password"
required required
autocomplete="current-password" autocomplete="current-password"
> >
<div class="auth-actions">
<button class="button button-primary" type="submit">Login</button> <button class="button button-primary" type="submit">Login</button>
</div> </fieldset>
</fieldset> </form>
</form> </div>
<div class="auth-footer"> <!-- Register Form -->
<p>Don't have an account? <a href="/register">Register here</a></p> <div class="auth-card">
<h2>Register</h2>
<p class="auth-card-subtitle">Create a new account</p>
<form method="POST" action="/register">
<fieldset>
<label for="register-username">Username</label>
<input
type="text"
id="register-username"
name="username"
placeholder="3-20 characters"
required
minlength="3"
maxlength="20"
pattern="[a-zA-Z0-9_]+"
autocomplete="username"
>
<small>Letters, numbers, underscore only</small>
<label for="register-password">Password</label>
<input
type="password"
id="register-password"
name="password"
placeholder="Min 8 characters"
required
minlength="8"
autocomplete="new-password"
>
<small>Uppercase, lowercase, and number</small>
<label for="register-confirm">Confirm Password</label>
<input
type="password"
id="register-confirm"
name="confirmPassword"
placeholder="Re-enter password"
required
minlength="8"
autocomplete="new-password"
>
<button class="button button-primary" type="submit">Create Account</button>
</fieldset>
</form>
</div> </div>
</div> </div>
<div class="auth-features"> <div class="auth-features-unified">
<h3>Account Features</h3> <h3>✓ Account Benefits</h3>
<ul> <div class="features-grid">
<li>✓ Forward emails to verified addresses</li> <div class="feature-item">Forward emails to verified addresses</div>
<li>✓ Lock and protect up to 5 inboxes</li> <div class="feature-item">Lock up to 5 inboxes with passwords</div>
<li>✓ Manage forwarding destinations</li> <div class="feature-item">Manage multiple forwarding destinations</div>
<li>✓ Access from any device</li> <div class="feature-item">Access your locked inboxes anywhere</div>
</ul>
<div class="auth-guest-section">
<h4>Guest Access</h4>
<p>You can still use temporary inboxes without an account, but forwarding and locking require registration.</p>
<a href="/" class="button">Browse as Guest</a>
</div> </div>
<p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,14 +3,37 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
<a href="/inbox/{{ address }}" aria-label="Return to inbox">← Return to inbox</a> <a href="/inbox/{{ address }}" aria-label="Return to inbox">← Return to inbox</a>
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward Email</a>
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete Email</a> {% if currentUser %}
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a> <!-- Email Dropdown (multiple actions when logged in) -->
{% if authEnabled and isLocked and hasAccess %} <div class="action-dropdown">
<a href="/lock/logout" aria-label="Logout">Logout</a> <button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
<div class="dropdown-menu">
<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 }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
</div>
</div>
<!-- Account Dropdown (logged in) -->
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/" aria-label="Home">Home</a>
</div>
</div>
{% endif %}
{% else %} {% else %}
<a href="/logout" aria-label="Logout">Logout</a> <!-- Simple buttons when not logged in -->
<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>
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %}
{% endif %} {% endif %}
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <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"> <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"/> <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"/>
@ -104,19 +127,38 @@
<div class="modal-content"> <div class="modal-content">
<span class="close" id="closeForward">&times;</span> <span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3> <h3>Forward Email</h3>
<p class="modal-description">Enter the email address to forward this message to.</p>
{% if errorMessage %} {% if not currentUser %}
<p class="unlock-error">{{ errorMessage }}</p> <p class="modal-description auth-required">
<strong>Login required:</strong> You must be logged in to forward emails.
</p>
<div class="auth-prompt">
<a href="/auth?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid)|url_encode }}" class="button button-primary">Login or Register</a>
</div>
{% elseif userForwardEmails|length == 0 %}
<p class="modal-description">You don't have any verified forwarding emails yet.</p>
<p class="modal-description">Add a verified email address in your account settings to enable forwarding.</p>
<a href="/account" class="button button-primary">Go to Account Settings</a>
{% else %}
<p class="modal-description">Select a verified email address to forward this message to.</p>
{% if errorMessage %}
<p class="unlock-error">{{ errorMessage }}</p>
{% endif %}
<p id="forwardError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
<fieldset>
<label for="forwardEmail" class="floating-label">Forward to</label>
<select id="forwardEmail" name="destinationEmail" required class="modal-input">
<option value="">Select an email...</option>
{% for email in userForwardEmails %}
<option value="{{ email.email }}">{{ email.email }}</option>
{% endfor %}
</select>
<small class="form-hint">Manage emails in <a href="/account">Account Settings</a></small>
<button type="submit" class="button-primary modal-button">Forward</button>
</fieldset>
</form>
{% endif %} {% endif %}
<p id="forwardError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
<fieldset>
<label for="forwardEmail" class="floating-label">Destination Email</label>
<input type="email" id="forwardEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<button type="submit" class="button-primary modal-button">Forward</button>
</fieldset>
</form>
</div> </div>
</div> </div>

View file

@ -16,6 +16,7 @@ const loginRouter = require('./routes/login')
const errorRouter = require('./routes/error') 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 { sanitizeHtmlTwigFilter } = require('./views/twig-filters') const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
const Helper = require('../../application/helper') const Helper = require('../../application/helper')
@ -43,20 +44,22 @@ app.use(express.json())
app.use(express.urlencoded({ extended: false })) app.use(express.urlencoded({ extended: false }))
// Cookie parser for signed cookies (email verification) // Cookie parser for signed cookies (email verification)
app.use(cookieParser(config.lock.sessionSecret)) app.use(cookieParser(config.user.sessionSecret))
// Session support (always enabled for forward verification and inbox locking) // Session support (always enabled for forward verification and inbox locking)
app.use(session({ app.use(session({
secret: config.lock.sessionSecret, secret: config.user.sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
})) }))
// Clear session when user goes Home so locked inboxes require password again // Clear lock session data when user goes Home (but preserve authentication)
app.get('/', (req, res, next) => { app.get('/', (req, res, next) => {
if (req.session) { if (req.session && req.session.lockedInbox) {
req.session.destroy(() => next()) // Only clear lock-related data, preserve user authentication
delete req.session.lockedInbox
req.session.save(() => next())
} else { } else {
next() next()
} }
@ -87,6 +90,19 @@ app.use(
) )
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter) Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
// Middleware to expose user session to all templates
app.use((req, res, next) => {
res.locals.authEnabled = config.user.authEnabled
res.locals.currentUser = null
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
res.locals.currentUser = {
id: req.session.userId,
username: req.session.username
}
}
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')
@ -99,6 +115,7 @@ app.use((req, res, next) => {
app.use('/', loginRouter) app.use('/', loginRouter)
if (config.user.authEnabled) { if (config.user.authEnabled) {
app.use('/', authRouter) app.use('/', authRouter)
app.use('/', accountRouter)
} }
app.use('/inbox', inboxRouter) app.use('/inbox', inboxRouter)
app.use('/error', errorRouter) app.use('/error', errorRouter)