[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_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
SMTP_PASSWORD="password" # SMTP authentication password
SMTP_FROM_NAME="48hr Email Service" # Display name for forwarded emails
# --- HTTP / WEB CONFIGURATION ---
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)
# Database Paths
USER_DATABASE_PATH="./db/users.db" # Path to user database
LOCK_DATABASE_PATH="./db/locked-inboxes.db" # Path to lock database
USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks)
# Feature Limits
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 debug = require('debug')('48hr-email:app')
const Helper = require('./application/helper')
const { app, io, server } = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification')
@ -20,31 +21,23 @@ const clientNotification = new ClientNotification()
debug('Client notification service initialized')
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)
debug('SMTP service initialized')
app.set('smtpService', smtpService)
const verificationStore = new VerificationStore()
debug('Verification store initialized')
app.set('verificationStore', verificationStore)
// Set config in app for route access
app.set('config', config)
// Initialize user repository and auth service (if enabled)
let inboxLock = null
if (config.user.authEnabled) {
// Migrate legacy database files for backwards compatibility
Helper.migrateDatabase(config.user.databasePath)
const userRepository = new UserRepository(config.user.databasePath)
debug('User repository initialized')
app.set('userRepository', userRepository)
@ -52,13 +45,33 @@ if (config.user.authEnabled) {
const authService = new AuthService(userRepository, config)
debug('Auth service initialized')
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')
} else {
app.set('userRepository', null)
app.set('authService', null)
app.set('inboxLock', null)
debug('User authentication system disabled')
}
const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized')
const mailProcessingService = new MailProcessingService(
new MailRepository(),
imapService,

View file

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

View file

@ -232,6 +232,36 @@ class Helper {
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

View file

@ -261,7 +261,8 @@ class MailProcessingService extends EventEmitter {
// Forward via SMTP service
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) {
debug(`Email forwarded successfully. MessageId: ${result.messageId}`)

View file

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

View file

@ -1,94 +1,221 @@
const Database = require('better-sqlite3')
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 {
constructor(dbPath = './db/locked-inboxes.db') {
// Ensure data directory exists
const fs = require('fs')
const dir = path.dirname(dbPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
constructor(userRepository) {
this.userRepository = userRepository
this.db = userRepository.db
debug('InboxLock initialized with user database')
}
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this._initTable()
/**
* Lock an inbox for a user (no separate password needed - uses account ownership)
* @param {number} userId - User ID
* @param {string} address - Inbox address to lock
* @returns {Promise<boolean>} - Success status
*/
async lock(userId, address) {
try {
// Check if user can lock more inboxes (5 max)
if (!this.canLockMore(userId)) {
throw new Error('You have reached the maximum of 5 locked inboxes')
}
_initTable() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS locked_inboxes (
address TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
locked_at INTEGER NOT NULL,
last_access INTEGER NOT NULL
)
`)
// Check if inbox is already locked
if (this.isLocked(address)) {
throw new Error('This inbox is already locked')
}
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 (?, ?, ?, ?)
INSERT INTO user_locked_inboxes (user_id, inbox_address, password_hash, locked_at, last_accessed)
VALUES (?, ?, ?, ?, ?)
`)
try {
stmt.run(address.toLowerCase(), passwordHash, now, now)
// 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) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('This inbox is already locked')
}
debug(`Failed to lock inbox ${address}:`, error.message)
throw error
}
}
async unlock(address, password) {
const stmt = this.db.prepare('SELECT * FROM locked_inboxes WHERE address = ?')
const inbox = stmt.get(address.toLowerCase())
/**
* Unlock an inbox (verify user owns the lock)
* @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) {
return null
}
const valid = await bcrypt.compare(password, inbox.password_hash)
if (!valid) {
if (!lock) {
debug(`No lock found for user ${userId} on inbox ${address}`)
return null
}
// Update last access
this.updateAccess(address)
return inbox
this.updateAccess(userId, address)
debug(`Inbox ${address} unlocked by user ${userId}`)
return lock
} catch (error) {
debug(`Error unlocking inbox ${address}:`, error.message)
return null
}
}
/**
* Check if an inbox is locked by any user
* @param {string} address - Inbox address
* @returns {boolean} - True if locked
*/
isLocked(address) {
const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE address = ?')
return stmt.get(address.toLowerCase()) !== undefined
const stmt = this.db.prepare(`
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 = ?')
stmt.run(Date.now(), address.toLowerCase())
/**
* Check if an inbox is locked by a specific user
* @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) {
const cutoff = Date.now() - (hoursThreshold * 60 * 60 * 1000)
const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE last_access < ?')
return stmt.all(cutoff).map(row => row.address)
const stmt = this.db.prepare(`
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 = ?')
stmt.run(address.toLowerCase())
/**
* Release (unlock) an inbox
* @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() {
const stmt = this.db.prepare('SELECT address FROM locked_inboxes')
return stmt.all().map(row => row.address)
const stmt = this.db.prepare('SELECT inbox_address FROM user_locked_inboxes')
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`
}
}

View file

@ -186,7 +186,7 @@ class UserRepository {
/**
* Get all verified forwarding emails for a user
* @param {number} userId
* @returns {Array} - Array of email objects
* @returns {Array} - Array of email objects with formatted timestamps
*/
getForwardEmails(userId) {
try {
@ -197,14 +197,37 @@ class UserRepository {
ORDER BY created_at DESC
`)
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}`)
return emails
return formatted
} catch (error) {
debug(`Error getting forward emails: ${error.message}`)
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
* @param {number} userId
@ -276,9 +299,10 @@ class UserRepository {
/**
* Get user statistics
* @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 {
const user = this.getUserById(userId)
if (!user) {
@ -294,7 +318,8 @@ class UserRepository {
const lockedInboxesCount = lockedInboxesStmt.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`)
@ -303,7 +328,10 @@ class UserRepository {
forwardEmailsCount,
accountAge,
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) {
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
*/

View file

@ -27,13 +27,13 @@ function requireAuth(req, res, next) {
}
// 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
req.session.redirectAfterLogin = req.originalUrl
// Redirect to login
return res.redirect('/login')
// Redirect to auth page
return res.redirect('/auth')
}
/**

View file

@ -1,13 +1,20 @@
function checkLockAccess(req, res, next) {
const inboxLock = req.app.get('inboxLock')
const address = req.params.address
const userId = req.session ? .userId
const isAuthenticated = req.session ? .isAuthenticated
if (!address || !inboxLock) {
return next()
}
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
if (isLocked && !hasAccess) {
@ -19,17 +26,16 @@ function checkLockAccess(req, res, next) {
purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(),
address: address,
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,
showUnlockButton: true,
unlockError: unlockError,
redirectTo: req.originalUrl
currentUser: req.session ? .username,
authEnabled: req.app.get('config').user.authEnabled
})
}
// Update last access if they have access
if (isLocked && hasAccess) {
inboxLock.updateAccess(address)
// Update last access if they have access and are authenticated
if (isLocked && hasAccess && isAuthenticated && userId) {
inboxLock.updateAccess(userId, address)
}
next()

View file

@ -114,10 +114,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeLock = document.getElementById('closeLock');
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 removeLockBtn = document.getElementById('removeLockBtn');
const closeRemoveLock = document.getElementById('closeRemoveLock');
@ -136,35 +132,7 @@ document.addEventListener('DOMContentLoaded', () => {
closeLock.onclick = function() { closeModal(lockModal); };
}
if (lockForm) {
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';
});
}
// Lock form no longer needs password validation - authentication-based locking
if (lockModal) {
const lockErrorValue = (lockModal.dataset.lockError || '').trim();
@ -176,8 +144,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (err) {
if (lockErrorValue === 'locking_disabled_for_example') {
err.textContent = 'Locking is disabled for the example inbox.';
} else if (lockErrorValue === 'invalid_password') {
err.textContent = 'Please provide a valid password.';
} else if (lockErrorValue === 'max_locked_inboxes') {
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') {
err.textContent = 'A server error occurred. Please try again.';
} 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) {
removeLockBtn.onclick = function(e) {
e.preventDefault();
@ -219,7 +175,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.onclick = function(e) {
if (e.target === lockModal) closeModal(lockModal);
if (e.target === unlockModal) closeModal(unlockModal);
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-container {
@ -366,7 +482,6 @@ text-muted {
background: var(--color-accent-purple);
color: white;
border: none;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
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-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 */
input,
@ -1105,7 +1540,7 @@ label {
.attachment-link {
color: var(--color-accent-purple-light);
padding: 12px 16px;
padding: 0px 16px;
background: var(--overlay-purple-10);
border: 1px solid var(--overlay-purple-20);
transition: all 0.3s ease;
@ -1311,7 +1746,7 @@ label {
}
.raw-tab-button {
padding: 8px 14px;
padding: 0px 14px;
border-radius: 10px;
border: 1px solid var(--overlay-white-12);
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()
}
// GET /register - Show registration form
router.get('/register', redirectIfAuthenticated, (req, res) => {
// GET /auth - Show unified auth page (login or register)
router.get('/auth', redirectIfAuthenticated, (req, res) => {
const config = req.app.get('config')
const errorMessage = req.session.errorMessage
const successMessage = req.session.successMessage
@ -84,8 +84,8 @@ router.get('/register', redirectIfAuthenticated, (req, res) => {
delete req.session.errorMessage
delete req.session.successMessage
res.render('register', {
title: `Register | ${config.http.branding[0]}`,
res.render('login-auth', {
title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding,
errorMessage,
successMessage
@ -106,7 +106,7 @@ router.post('/register',
const firstError = errors.array()[0].msg
debug(`Registration validation failed: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect('/register')
return res.redirect('/auth')
}
const { username, password, confirmPassword } = req.body
@ -115,7 +115,7 @@ router.post('/register',
if (password !== confirmPassword) {
debug('Registration failed: 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')
@ -124,39 +124,21 @@ router.post('/register',
if (result.success) {
debug(`User registered successfully: ${username}`)
req.session.successMessage = 'Registration successful! Please log in.'
return res.redirect('/login')
return res.redirect('/auth')
} else {
debug(`Registration failed: ${result.error}`)
req.session.errorMessage = result.error
return res.redirect('/register')
return res.redirect('/auth')
}
} catch (error) {
debug(`Registration error: ${error.message}`)
console.error('Error during registration', error)
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
router.post('/login',
redirectIfAuthenticated,
@ -170,7 +152,7 @@ router.post('/login',
const firstError = errors.array()[0].msg
debug(`Login validation failed: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect('/login')
return res.redirect('/auth')
}
const { username, password } = req.body
@ -187,7 +169,7 @@ router.post('/login',
if (err) {
debug(`Session regeneration error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/login')
return res.redirect('/auth')
}
// Set session data
@ -200,7 +182,7 @@ router.post('/login',
if (err) {
debug(`Session save error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/login')
return res.redirect('/auth')
}
debug(`Session created for user: ${username}`)
@ -210,13 +192,13 @@ router.post('/login',
} else {
debug(`Login failed: ${result.error}`)
req.session.errorMessage = result.error
return res.redirect('/login')
return res.redirect('/auth')
}
} catch (error) {
debug(`Login error: ${error.message}`)
console.error('Error during login', error)
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 cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock')
const { requireAuth, optionalAuth } = require('../middleware/auth')
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 {
const mailProcessingService = req.app.get('mailProcessingService')
if (!mailProcessingService) {
@ -109,8 +110,25 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
const largestUid = await req.app.locals.imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid)
debug(`Rendering inbox with ${count} total mails`)
// Check lock status
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
const lockError = req.session ? req.session.lockError : undefined
@ -138,6 +156,8 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
branding: config.http.branding,
authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
hasAccess: hasAccess,
unlockError: unlockErrorSession,
@ -163,6 +183,7 @@ router.get(
'^/:address/:uid([0-9]+)',
sanitizeAddress,
validateDomain,
optionalAuth,
checkLockAccess,
async(req, res, next) => {
try {
@ -190,7 +211,22 @@ router.get(
const inboxLock = req.app.get('inboxLock')
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
const errorMessage = req.session ? req.session.errorMessage : undefined
@ -217,6 +253,8 @@ router.get(
uid: req.params.uid,
branding: config.http.branding,
authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
hasAccess: hasAccess,
errorMessage: errorMessage,
@ -418,9 +456,10 @@ router.get(
}
)
// POST route for forwarding a single email
// POST route for forwarding a single email (requires authentication)
router.post(
'^/:address/:uid/forward',
requireAuth,
forwardLimiter,
validateDomain,
checkLockAccess,
@ -436,15 +475,22 @@ router.post(
}
const mailProcessingService = req.app.get('mailProcessingService')
const userRepository = req.app.get('userRepository')
const { destinationEmail } = req.body
const uid = parseInt(req.params.uid, 10)
// Check if destination email is verified via signed cookie
const verifiedEmail = req.signedCookies.verified_email
// Check if destination email is in user's verified emails
const userEmails = userRepository.getForwardEmails(req.session.userId)
const isVerified = userEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase())
if (!isVerified) {
debug(`Email ${destinationEmail} not in user's verified emails`)
req.session.errorMessage = 'Please select a verified email address from your account'
return res.redirect(`/inbox/${req.params.address}/${req.params.uid}`)
}
if (verifiedEmail && verifiedEmail.toLowerCase() === destinationEmail.toLowerCase()) {
// Email is verified, proceed with forwarding
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (verified)`)
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (user verified)`)
const result = await mailProcessingService.forwardEmail(
req.params.address,
@ -460,28 +506,6 @@ router.post(
req.session.errorMessage = result.error
return res.redirect(`/inbox/${req.params.address}/${uid}`)
}
} else {
// Email not verified, initiate verification flow
debug(`Email ${destinationEmail} not verified, initiating verification`)
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) {
debug(`Error forwarding email ${req.params.uid}: ${error.message}`)
console.error('Error while forwarding email', error)
@ -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(
'^/:address/forward-all',
requireAuth,
forwardLimiter,
validateDomain,
checkLockAccess,
@ -509,40 +534,21 @@ router.post(
}
const mailProcessingService = req.app.get('mailProcessingService')
const userRepository = req.app.get('userRepository')
const { destinationEmail } = req.body
// Check if destination email is verified via signed cookie
const verifiedEmail = req.signedCookies.verified_email
// Check if destination email is in user's verified emails
const userEmails = userRepository.getForwardEmails(req.session.userId)
const isVerified = userEmails.some(e => e.email.toLowerCase() === destinationEmail.toLowerCase())
if (!verifiedEmail || verifiedEmail.toLowerCase() !== destinationEmail.toLowerCase()) {
// Email not verified, initiate verification flow
debug(`Email ${destinationEmail} not verified, initiating verification for forward-all`)
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
if (!isVerified) {
debug(`Email ${destinationEmail} not in user's verified emails`)
req.session.errorMessage = 'Please select a verified email address from your account'
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
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)

View file

@ -1,13 +1,15 @@
const express = require('express')
const router = express.Router()
const debug = require('debug')('48hr-email:lock')
const { requireAuth } = require('../middleware/auth')
router.post('/lock', async(req, res) => {
const { address, password } = req.body
debug(`Lock attempt for inbox: ${address}`);
router.post('/lock', requireAuth, async(req, res) => {
const { address } = req.body
const userId = req.session.userId
debug(`Lock attempt for inbox: ${address} by user ${userId}`)
if (!address || !password || password.length < 8) {
debug(`Lock error for ${address}: invalid input`);
if (!address) {
debug(`Lock error for ${address}: missing address`)
if (req.session) req.session.lockError = 'invalid'
return res.redirect(`/inbox/${address}`)
}
@ -17,58 +19,88 @@ router.post('/lock', async(req, res) => {
const mailProcessingService = req.app.get('mailProcessingService')
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()) {
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'
return res.redirect(`/inbox/${address}`)
}
await inboxLock.lock(address, password)
debug(`Inbox locked: ${address}`);
// Check if user can lock more inboxes (5 max)
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
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug(`Clearing lock cache for: ${address}`);
debug(`Clearing lock cache for: ${address}`)
mailProcessingService.cachedFetchFullMail.clear()
}
// Store in session for immediate access
req.session.lockedInbox = address
res.redirect(`/inbox/${address}`)
} catch (error) {
debug(`Lock error for ${address}: ${error.message}`);
debug(`Lock error for ${address}: ${error.message}`)
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}`)
}
})
router.post('/unlock', async(req, res) => {
const { address, password, redirectTo } = req.body
router.post('/unlock', requireAuth, async(req, res) => {
const { address, redirectTo } = req.body
const userId = req.session.userId
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) {
debug(`Unlock error for ${address}: missing fields`);
if (!address) {
debug(`Unlock error for ${address}: missing address`)
if (req.session) req.session.unlockError = 'missing_fields'
return res.redirect(destination)
}
try {
const inboxLock = req.app.get('inboxLock')
const inbox = await inboxLock.unlock(address, password)
if (!inbox) {
debug(`Unlock error for ${address}: invalid password`);
if (req.session) req.session.unlockError = 'invalid_password'
if (!inboxLock) {
debug('Unlock error: inboxLock service not available')
if (req.session) req.session.unlockError = 'service_unavailable'
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
res.redirect(destination)
} catch (error) {
debug(`Unlock error for ${address}: ${error.message}`);
debug(`Unlock error for ${address}: ${error.message}`)
console.error('Unlock error:', error)
if (req.session) req.session.unlockError = 'server_error'
res.redirect(destination)
@ -80,51 +112,58 @@ router.get('/logout', (req, res) => {
// Clear cache before logout
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug('Clearing lock cache for logout');
debug('Clearing lock cache for logout')
mailProcessingService.cachedFetchFullMail.clear()
}
debug('Lock session destroyed (logout)');
req.session.destroy()
debug('Clearing lockedInbox from session (lock logout)')
delete req.session.lockedInbox
res.redirect('/')
})
router.post('/remove', async(req, res) => {
router.post('/remove', requireAuth, async(req, res) => {
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) {
debug('Remove lock error: missing address');
debug('Remove lock error: missing address')
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 {
const inboxLock = req.app.get('inboxLock')
const mailProcessingService = req.app.get('mailProcessingService')
await inboxLock.release(address)
debug(`Lock removed for inbox: ${address}`);
if (!inboxLock) {
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
if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) {
debug(`Clearing lock cache for: ${address}`);
debug(`Clearing lock cache for: ${address}`)
mailProcessingService.cachedFetchFullMail.clear()
}
debug('Lock session destroyed (remove)');
req.session.destroy()
// Clear from session
if (req.session.lockedInbox === address.toLowerCase()) {
delete req.session.lockedInbox
}
res.redirect(`/inbox/${address}`)
} catch (error) {
debug(`Remove lock error for ${address}: ${error.message}`);
debug(`Remove lock error for ${address}: ${error.message}`)
console.error('Remove lock error:', error)
if (req.session) req.session.lockError = 'remove_failed'
res.redirect(`/inbox/${address}`)

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 %}
<div class="action-links">
{% if showUnlockButton %}
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a>
{% if authEnabled and not currentUser %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% 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">
<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"/>
@ -21,48 +21,4 @@
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<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 %}

View file

@ -2,22 +2,41 @@
{% block header %}
<div class="action-links">
{% if currentUser %}
<!-- Inbox Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu">
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
{% if authEnabled %}
{% if isLocked and hasAccess %}
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a>
{% elseif isLocked %}
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a>
{% else %}
<a href="#" id="lockBtn" aria-label="Protect inbox with password">Protect Inbox</a>
<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="#" 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 %}
</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 %}
<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"/>
@ -93,21 +112,22 @@
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
<div class="modal-content">
<span class="close" id="closeLock">&times;</span>
<h3>Protect 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>
<h3>Lock Inbox</h3>
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
{% if error and error == 'locking_disabled_for_example' %}
<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 %}
<p id="lockErrorInline" class="unlock-error" style="display:none"></p>
<form method="POST" action="/lock/lock">
<input type="hidden" name="address" value="{{ address }}">
<fieldset>
<label for="lockPassword" class="floating-label">Password (min 8 characters)</label>
<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">
<p>This inbox will be protected with your account. Only you will be able to access it while logged in.</p>
<button type="submit" class="button-primary modal-button">Lock Inbox</button>
</fieldset>
</form>
@ -116,45 +136,15 @@
{% 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 %}
<!-- Remove Lock Modal -->
<div id="removeLockModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeRemoveLock">&times;</span>
<h3>Remove Password Lock</h3>
<p class="modal-description">Are you sure you want to remove the password lock from this inbox? This cannot be undone.</p>
<h3>Remove Lock</h3>
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>
<form method="POST" action="/lock/remove">
<input type="hidden" name="address" value="{{ address }}">
<fieldset>
@ -183,19 +173,38 @@
<div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span>
<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 not currentUser %}
<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">Destination Email</label>
<input type="email" id="forwardAllEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<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 %}
</div>
</div>
{% endblock %}

View file

@ -2,8 +2,7 @@
{% block header %}
<div class="action-links">
<a href="/" aria-label="Return to home">← Home</a>
<a href="/register" aria-label="Register">Register</a>
<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"/>
@ -16,70 +15,110 @@
{% endblock %}
{% block body %}
<div id="login-auth" class="auth-container">
<div class="auth-card">
<h1>Welcome Back</h1>
<p class="auth-subtitle">Login to access your account</p>
<div id="auth-unified" class="auth-unified-container">
<div class="auth-intro">
<h1>Account Access</h1>
<p class="auth-subtitle">Login to an existing account or create a new one</p>
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
<div class="unlock-error">{{ errorMessage }}</div>
{% endif %}
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
</div>
<div class="success-message">{{ successMessage }}</div>
{% endif %}
</div>
<div class="auth-forms-grid">
<!-- Login Form -->
<div class="auth-card">
<h2>Login</h2>
<p class="auth-card-subtitle">Access your existing account</p>
<form method="POST" action="/login">
<fieldset>
<label for="username">Username</label>
<label for="login-username">Username</label>
<input
type="text"
id="username"
id="login-username"
name="username"
placeholder="Enter your username"
placeholder="Your username"
required
autocomplete="username"
>
<label for="password">Password</label>
<label for="login-password">Password</label>
<input
type="password"
id="password"
id="login-password"
name="password"
placeholder="Enter your password"
placeholder="Your password"
required
autocomplete="current-password"
>
<div class="auth-actions">
<button class="button button-primary" type="submit">Login</button>
</div>
</fieldset>
</form>
</div>
<div class="auth-footer">
<p>Don't have an account? <a href="/register">Register here</a></p>
<!-- Register Form -->
<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 class="auth-features">
<h3>Account Features</h3>
<ul>
<li>✓ Forward emails to verified addresses</li>
<li>✓ Lock and protect up to 5 inboxes</li>
<li>✓ Manage forwarding destinations</li>
<li>✓ Access from any device</li>
</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 class="auth-features-unified">
<h3>✓ Account Benefits</h3>
<div class="features-grid">
<div class="feature-item">Forward emails to verified addresses</div>
<div class="feature-item">Lock up to 5 inboxes with passwords</div>
<div class="feature-item">Manage multiple forwarding destinations</div>
<div class="feature-item">Access your locked inboxes anywhere</div>
</div>
<p class="guest-note">No account needed for basic temporary inboxes • <a href="/">Browse as guest</a></p>
</div>
</div>
{% endblock %}

View file

@ -3,14 +3,37 @@
{% block header %}
<div class="action-links">
<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 %}
<!-- Email Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<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>
{% if authEnabled and isLocked and hasAccess %}
<a href="/lock/logout" aria-label="Logout">Logout</a>
{% else %}
<a href="/logout" aria-label="Logout">Logout</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 %}
<!-- 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 %}
<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"/>
@ -104,19 +127,38 @@
<div class="modal-content">
<span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3>
<p class="modal-description">Enter the email address to forward this message to.</p>
{% if not currentUser %}
<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">Destination Email</label>
<input type="email" id="forwardEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<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 %}
</div>
</div>

View file

@ -16,6 +16,7 @@ const loginRouter = require('./routes/login')
const errorRouter = require('./routes/error')
const lockRouter = require('./routes/lock')
const authRouter = require('./routes/auth')
const accountRouter = require('./routes/account')
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
const Helper = require('../../application/helper')
@ -43,20 +44,22 @@ app.use(express.json())
app.use(express.urlencoded({ extended: false }))
// 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)
app.use(session({
secret: config.lock.sessionSecret,
secret: config.user.sessionSecret,
resave: false,
saveUninitialized: false,
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) => {
if (req.session) {
req.session.destroy(() => next())
if (req.session && req.session.lockedInbox) {
// Only clear lock-related data, preserve user authentication
delete req.session.lockedInbox
req.session.save(() => next())
} else {
next()
}
@ -87,6 +90,19 @@ app.use(
)
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
app.use((req, res, next) => {
const isImapReady = req.app.get('isImapReady')
@ -99,6 +115,7 @@ app.use((req, res, next) => {
app.use('/', loginRouter)
if (config.user.authEnabled) {
app.use('/', authRouter)
app.use('/', accountRouter)
}
app.use('/inbox', inboxRouter)
app.use('/error', errorRouter)