From 004d7642380c6f4cc8b3faf07ecf63dd13b74cb1 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Fri, 2 Jan 2026 18:49:57 +0100 Subject: [PATCH] [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 --- .env.example | 4 +- app.js | 45 +- application/config.js | 6 +- application/helper.js | 30 ++ application/mail-processing-service.js | 3 +- application/smtp-service.js | 14 +- domain/inbox-lock.js | 253 +++++++--- domain/user-repository.js | 55 ++- infrastructure/web/middleware/auth.js | 6 +- infrastructure/web/middleware/lock.js | 24 +- .../web/public/javascripts/utils.js | 55 +-- .../web/public/stylesheets/custom.css | 441 +++++++++++++++++- infrastructure/web/routes/account.js | 228 +++++++++ infrastructure/web/routes/auth.js | 46 +- infrastructure/web/routes/inbox.js | 148 +++--- infrastructure/web/routes/lock.js | 129 +++-- infrastructure/web/views/account.twig | 170 +++++++ infrastructure/web/views/error.twig | 50 +- infrastructure/web/views/inbox.twig | 143 +++--- infrastructure/web/views/login-auth.twig | 145 +++--- infrastructure/web/views/mail.twig | 78 +++- infrastructure/web/web.js | 27 +- 22 files changed, 1598 insertions(+), 502 deletions(-) create mode 100644 infrastructure/web/routes/account.js create mode 100644 infrastructure/web/views/account.twig diff --git a/.env.example b/.env.example index e929505..68aec22 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app.js b/app.js index 0946e4b..ba64404 100644 --- a/app.js +++ b/app.js @@ -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, diff --git a/application/config.js b/application/config.js index a451eb4..80ddcdf 100644 --- a/application/config.js +++ b/application/config.js @@ -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', diff --git a/application/helper.js b/application/helper.js index f1c5992..2ceff91 100644 --- a/application/helper.js +++ b/application/helper.js @@ -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 diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js index c51ea25..5e22e28 100644 --- a/application/mail-processing-service.js +++ b/application/mail-processing-service.js @@ -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}`) diff --git a/application/smtp-service.js b/application/smtp-service.js index 1c6f2fe..87da49e 100644 --- a/application/smtp-service.js +++ b/application/smtp-service.js @@ -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 = ` diff --git a/domain/inbox-lock.js b/domain/inbox-lock.js index e34e916..3565549 100644 --- a/domain/inbox-lock.js +++ b/domain/inbox-lock.js @@ -1,95 +1,222 @@ -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 }) - } - - this.db = new Database(dbPath) - this.db.pragma('journal_mode = WAL') - this._initTable() + constructor(userRepository) { + this.userRepository = userRepository + this.db = userRepository.db + debug('InboxLock initialized with user database') } - _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 - ) - `) - } - - 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 (?, ?, ?, ?) - `) - + /** + * 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} - Success status + */ + async lock(userId, address) { try { - stmt.run(address.toLowerCase(), passwordHash, now, now) - return true - } catch (error) { - if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + // 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') + } + + // Check if inbox is already locked + if (this.isLocked(address)) { 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 } } - 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} - 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 } - - 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) { - 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} - 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} - 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} - 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 \ No newline at end of file +module.exports = InboxLock diff --git a/domain/user-repository.js b/domain/user-repository.js index 525dde2..21598d4 100644 --- a/domain/user-repository.js +++ b/domain/user-repository.js @@ -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 */ diff --git a/infrastructure/web/middleware/auth.js b/infrastructure/web/middleware/auth.js index deae5c6..ccd1a99 100644 --- a/infrastructure/web/middleware/auth.js +++ b/infrastructure/web/middleware/auth.js @@ -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') } /** diff --git a/infrastructure/web/middleware/lock.js b/infrastructure/web/middleware/lock.js index 329ddfa..7e8b4b8 100644 --- a/infrastructure/web/middleware/lock.js +++ b/infrastructure/web/middleware/lock.js @@ -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,20 +26,19 @@ 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() } -module.exports = { checkLockAccess } \ No newline at end of file +module.exports = { checkLockAccess } diff --git a/infrastructure/web/public/javascripts/utils.js b/infrastructure/web/public/javascripts/utils.js index f4283bf..9c1e935 100644 --- a/infrastructure/web/public/javascripts/utils.js +++ b/infrastructure/web/public/javascripts/utils.js @@ -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); }; } diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 6af2e43..a3051d9 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -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); diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js new file mode 100644 index 0000000..c9ce9b6 --- /dev/null +++ b/infrastructure/web/routes/account.js @@ -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 diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js index 83baf3c..f2f4287 100644 --- a/infrastructure/web/routes/auth.js +++ b/infrastructure/web/routes/auth.js @@ -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') } } ) diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index 14bea1d..b2c533c 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -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,51 +475,36 @@ 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 (verifiedEmail && verifiedEmail.toLowerCase() === destinationEmail.toLowerCase()) { - // Email is verified, proceed with forwarding - debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (verified)`) + 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}`) + } - const result = await mailProcessingService.forwardEmail( - req.params.address, - uid, - destinationEmail - ) + // Email is verified, proceed with forwarding + debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (user verified)`) - if (result.success) { - debug(`Email ${uid} forwarded successfully to ${destinationEmail}`) - return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`) - } else { - debug(`Failed to forward email ${uid}: ${result.error}`) - req.session.errorMessage = result.error - return res.redirect(`/inbox/${req.params.address}/${uid}`) - } + const result = await mailProcessingService.forwardEmail( + req.params.address, + uid, + destinationEmail + ) + + if (result.success) { + debug(`Email ${uid} forwarded successfully to ${destinationEmail}`) + return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`) } 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}`) - } + debug(`Failed to forward email ${uid}: ${result.error}`) + req.session.errorMessage = result.error + return res.redirect(`/inbox/${req.params.address}/${uid}`) } } catch (error) { 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( '^/: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 - 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}`) - } + 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}`) } // 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) diff --git a/infrastructure/web/routes/lock.js b/infrastructure/web/routes/lock.js index 2ba1845..a31cdf4 100644 --- a/infrastructure/web/routes/lock.js +++ b/infrastructure/web/routes/lock.js @@ -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,55 +112,62 @@ 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}`) } }) -module.exports = router \ No newline at end of file +module.exports = router diff --git a/infrastructure/web/views/account.twig b/infrastructure/web/views/account.twig new file mode 100644 index 0000000..468481b --- /dev/null +++ b/infrastructure/web/views/account.twig @@ -0,0 +1,170 @@ +{% extends 'layout.twig' %} + +{% block header %} + +{% endblock %} + +{% block body %} + + + + + + +{% endblock %} diff --git a/infrastructure/web/views/error.twig b/infrastructure/web/views/error.twig index 75d9999..e0e644d 100644 --- a/infrastructure/web/views/error.twig +++ b/infrastructure/web/views/error.twig @@ -2,10 +2,10 @@ {% block header %} - - - - {% endif %} {% endblock %} diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig index 4139d93..bb5b606 100644 --- a/infrastructure/web/views/inbox.twig +++ b/infrastructure/web/views/inbox.twig @@ -2,22 +2,41 @@ {% block header %}