diff --git a/.env.example b/.env.example index 35e3e4f..d89bf7b 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,9 @@ HTTP_DISPLAY_SORT=2 # Domain display # 2 = alphabetical + first item shuffled, # 3 = shuffle all HTTP_HIDE_OTHER=false # true = only show first domain, false = show all + +# --- INBOX LOCKING (optional) --- +LOCK_ENABLED=false # Enable inbox locking with passwords +LOCK_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption +LOCK_DATABASE_PATH="./db/locked-inboxes.db" # Path to lock database +LOCK_RELEASE_HOURS=720 # Auto-release locked inboxes after X hours of inactivity (default 30 days) diff --git a/README.md b/README.md index 9585a9b..dba457d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,6 @@ WantedBy=multi-user.target ### TODO (PRs welcome): - Add user registration: - Optional "premium" domains that arent visible to the public to prevent them from being scraped and flagged - - Allow people to set a password for their email (releases X time after last login) - Allow people to set up forwarding #### Unsure: diff --git a/app.js b/app.js index b60f996..309a80c 100644 --- a/app.js +++ b/app.js @@ -13,6 +13,7 @@ const ClientNotification = require('./infrastructure/web/client-notification') const ImapService = require('./application/imap-service') const MailProcessingService = require('./application/mail-processing-service') const MailRepository = require('./domain/mail-repository') +const InboxLock = require('./domain/inbox-lock') const clientNotification = new ClientNotification() debug('Client notification service initialized') @@ -55,6 +56,23 @@ imapService.on(ImapService.EVENT_ERROR, error => { }) app.set('mailProcessingService', mailProcessingService) +app.set('config', config) + +// Initialize inbox locking if enabled +if (config.lock.enabled) { + const inboxLock = new InboxLock(config.lock.dbPath) + app.set('inboxLock', inboxLock) + console.log(`Inbox locking enabled (auto-release after ${config.lock.releaseHours} hours)`) + + // Check for inactive locked inboxes + setInterval(() => { + const inactive = inboxLock.getInactive(config.lock.releaseHours) + if (inactive.length > 0) { + console.log(`Releasing ${inactive.length} inactive locked inbox(es)`) + inactive.forEach(address => inboxLock.release(address)) + } + }, config.imap.refreshIntervalSeconds * 1000) +} app.locals.imapService = imapService app.locals.mailProcessingService = mailProcessingService @@ -86,4 +104,4 @@ server.on('error', error => { console.error('Fatal web server error', error) process.exit(1) } -}) \ No newline at end of file +}) diff --git a/application/config.js b/application/config.js index 378bf47..990bd48 100644 --- a/application/config.js +++ b/application/config.js @@ -60,6 +60,13 @@ const config = { branding: parseValue(process.env.HTTP_BRANDING), displaySort: Number(process.env.HTTP_DISPLAY_SORT), hideOther: parseBool(process.env.HTTP_HIDE_OTHER) + }, + + lock: { + enabled: parseBool(process.env.LOCK_ENABLED) || false, + sessionSecret: parseValue(process.env.LOCK_SESSION_SECRET) || 'change-me-in-production', + dbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db', + releaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 720 // 30 days default } }; diff --git a/domain/inbox-lock.js b/domain/inbox-lock.js new file mode 100644 index 0000000..e34e916 --- /dev/null +++ b/domain/inbox-lock.js @@ -0,0 +1,95 @@ +const Database = require('better-sqlite3') +const bcrypt = require('bcrypt') +const path = require('path') + +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() + } + + _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 (?, ?, ?, ?) + `) + + try { + stmt.run(address.toLowerCase(), passwordHash, now, now) + return true + } catch (error) { + if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + throw new Error('This inbox is already locked') + } + throw error + } + } + + async unlock(address, password) { + const stmt = this.db.prepare('SELECT * FROM locked_inboxes WHERE address = ?') + const inbox = stmt.get(address.toLowerCase()) + + if (!inbox) { + return null + } + + const valid = await bcrypt.compare(password, inbox.password_hash) + if (!valid) { + return null + } + + // Update last access + this.updateAccess(address) + return inbox + } + + isLocked(address) { + const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE address = ?') + return stmt.get(address.toLowerCase()) !== undefined + } + + updateAccess(address) { + const stmt = this.db.prepare('UPDATE locked_inboxes SET last_access = ? WHERE address = ?') + stmt.run(Date.now(), address.toLowerCase()) + } + + 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) + } + + release(address) { + const stmt = this.db.prepare('DELETE FROM locked_inboxes WHERE address = ?') + stmt.run(address.toLowerCase()) + } + + getAllLocked() { + const stmt = this.db.prepare('SELECT address FROM locked_inboxes') + return stmt.all().map(row => row.address) + } +} + +module.exports = InboxLock \ No newline at end of file diff --git a/infrastructure/web/middleware/lock.js b/infrastructure/web/middleware/lock.js new file mode 100644 index 0000000..329ddfa --- /dev/null +++ b/infrastructure/web/middleware/lock.js @@ -0,0 +1,38 @@ +function checkLockAccess(req, res, next) { + const inboxLock = req.app.get('inboxLock') + const address = req.params.address + + if (!address || !inboxLock) { + return next() + } + + const isLocked = inboxLock.isLocked(address) + const hasAccess = req.session && req.session.lockedInbox === address.toLowerCase() + + // Block access to locked inbox without proper authentication + if (isLocked && !hasAccess) { + const count = req.app.get('mailProcessingService').getCount() + const unlockError = req.session ? req.session.unlockError : undefined + if (req.session) delete req.session.unlockError + + return res.render('error', { + purgeTime: require('../../../application/helper').prototype.purgeTimeElemetBuilder(), + address: address, + count: count, + message: 'This inbox is locked. Please unlock it to access.', + branding: req.app.get('config').http.branding, + showUnlockButton: true, + unlockError: unlockError, + redirectTo: req.originalUrl + }) + } + + // Update last access if they have access + if (isLocked && hasAccess) { + inboxLock.updateAccess(address) + } + + next() +} + +module.exports = { checkLockAccess } \ No newline at end of file diff --git a/infrastructure/web/public/javascripts/lock-modals.js b/infrastructure/web/public/javascripts/lock-modals.js new file mode 100644 index 0000000..6d8614b --- /dev/null +++ b/infrastructure/web/public/javascripts/lock-modals.js @@ -0,0 +1,136 @@ +document.addEventListener('DOMContentLoaded', function() { + // Lock modal elements + const lockModal = document.getElementById('lockModal'); + const lockBtn = document.getElementById('lockBtn'); + const closeLock = document.getElementById('closeLock'); + const lockForm = document.querySelector('#lockModal form'); + + // Unlock modal elements + const unlockModal = document.getElementById('unlockModal'); + const unlockBtn = document.getElementById('unlockBtn'); + const closeUnlock = document.getElementById('closeUnlock'); + + // Remove lock modal elements + const removeLockModal = document.getElementById('removeLockModal'); + const removeLockBtn = document.getElementById('removeLockBtn'); + const closeRemoveLock = document.getElementById('closeRemoveLock'); + const cancelRemoveLock = document.getElementById('cancelRemoveLock'); + + // Open/close helpers + const openModal = function(modal) { if (modal) modal.style.display = 'block'; }; + const closeModal = function(modal) { if (modal) modal.style.display = 'none'; }; + + // Protect / Lock modal logic + if (lockBtn) { + lockBtn.onclick = function(e) { + e.preventDefault(); + openModal(lockModal); + }; + } + if (closeLock) { + 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'; + }); + } + + // Auto-open lock modal if server provided an error (via data attribute) + if (lockModal) { + const lockErrorValue = (lockModal.dataset.lockError || '').trim(); + if (lockErrorValue) { + openModal(lockModal); + const err = document.getElementById('lockErrorInline'); + const serverErr = document.getElementById('lockServerError'); + if (serverErr) serverErr.style.display = 'none'; + + if (err) { + if (lockErrorValue === 'locking_disabled_for_example') { + err.textContent = 'Locking is disabled for the example inbox.'; + } else if (lockErrorValue === 'invalid') { + err.textContent = 'Please provide a valid password.'; + } else if (lockErrorValue === 'server_error') { + err.textContent = 'A server error occurred. Please try again.'; + } else if (lockErrorValue === 'remove_failed') { + err.textContent = 'Failed to remove lock. Please try again.'; + } else { + err.textContent = 'An error occurred. Please try again.'; + } + err.style.display = 'block'; + } + } + } + + // Unlock modal logic + 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); + } + } + + // Remove lock modal logic + if (removeLockBtn) { + removeLockBtn.onclick = function(e) { + e.preventDefault(); + openModal(removeLockModal); + }; + } + if (closeRemoveLock) { + closeRemoveLock.onclick = function() { + closeModal(removeLockModal); + }; + } + if (cancelRemoveLock) { + cancelRemoveLock.onclick = function() { + closeModal(removeLockModal); + }; + } + + // Close modals when clicking outside + 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 e9274a6..184b019 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -541,4 +541,114 @@ label { border-color: rgba(155, 77, 202, 0.3); transform: translateX(5px); text-decoration: none; +} + + +/* Modal Styles */ + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.8); +} + +.modal-content { + background-color: #131516; + margin: 10% auto; + padding: 2rem; + border: 1px solid #9b4dca; + border-radius: 0.4rem; + width: 90%; + max-width: 400px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.modal-content h3 { + margin-top: 0; + color: #cccccc; +} + +.modal-description { + color: #999; + margin-bottom: 1.5rem; + font-size: 1.4rem; +} + +.close { + float: right; + font-size: 2.8rem; + font-weight: bold; + cursor: pointer; + color: #cccccc; + line-height: 20px; + transition: color 0.3s; +} + +.close:hover, +.close:focus { + color: #9b4dca; +} + +.floating-label { + position: relative; + top: 12px; + left: 12px; + width: fit-content; + line-height: 1; + padding: 0 6px; + font-size: 1.4rem; + background-color: #131516; + z-index: 999; + color: #cccccc; +} + +.modal-input { + border-radius: 0.4rem; + color: #cccccc; + font-size: 1.6rem; + height: 4.2rem; + padding: 0 1.4rem; + margin-bottom: 1rem; + background-color: transparent; + border: 1px solid #444; + width: 100%; +} + +.modal-input:focus { + border-color: #9b4dca; + outline: none; +} + +.modal-button { + width: 100%; + margin-top: 1rem; + background-color: #9b4dca; +} + + +/* Lock Error Messages */ + +.unlock-error { + color: #ff8c00; + margin-bottom: 1rem; + padding: 0.8rem; + background: rgba(255, 140, 0, 0.1); + border-left: 3px solid #ff8c00; +} + + +/* Remove Lock Button Styles */ + +.modal-button-danger { + background-color: #e74c3c; +} + +.modal-button-cancel { + background-color: #555; } \ No newline at end of file diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index fe22107..12b052c 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -6,6 +6,7 @@ const debug = require('debug')('48hr-email:routes') const config = require('../../../application/config') const Helper = require('../../../application/helper') const helper = new(Helper) +const { checkLockAccess } = require('../middleware/lock') const purgeTime = helper.purgeTimeElemetBuilder() @@ -18,17 +19,29 @@ const sanitizeAddress = param('address').customSanitizer( } ) -router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, async(req, res, next) => { +router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') if (!mailProcessingService) { throw new Error('Mail processing service not available') } debug(`Inbox request for ${req.params.address}`) + const inboxLock = req.app.get('inboxLock') const count = await mailProcessingService.getCount() const largestUid = await req.app.locals.imapService.getLargestUid() const totalcount = helper.countElementBuilder(count, largestUid) debug(`Rendering inbox with ${count} total mails`) + const isLocked = inboxLock && inboxLock.isLocked(req.params.address) + const hasAccess = req.session && req.session.lockedInbox === req.params.address + + // Pull any lock error from session and clear it after reading + const lockError = req.session ? req.session.lockError : undefined + const unlockErrorSession = req.session ? req.session.unlockError : undefined + if (req.session) { + delete req.session.lockError + delete req.session.unlockError + } + res.render('inbox', { title: `${config.http.branding[0]} | ` + req.params.address, purgeTime: purgeTime, @@ -37,6 +50,12 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, async(req, res, next) = totalcount: totalcount, mailSummaries: mailProcessingService.getMailSummaries(req.params.address), branding: config.http.branding, + lockEnabled: config.lock.enabled, + isLocked: isLocked, + hasAccess: hasAccess, + unlockError: unlockErrorSession, + error: lockError, + redirectTo: req.originalUrl }) } catch (error) { debug(`Error loading inbox for ${req.params.address}:`, error.message) @@ -48,6 +67,7 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, async(req, res, next) = router.get( '^/:address/:uid([0-9]+)', sanitizeAddress, + checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') @@ -67,6 +87,11 @@ router.get( // Emails are immutable, cache if found res.set('Cache-Control', 'private, max-age=600') + + const inboxLock = req.app.get('inboxLock') + const isLocked = inboxLock && inboxLock.isLocked(req.params.address) + const hasAccess = req.session && req.session.lockedInbox === req.params.address + debug(`Rendering email view for UID ${req.params.uid}`) res.render('mail', { title: mail.subject + " | " + req.params.address, @@ -77,6 +102,9 @@ router.get( mail, uid: req.params.uid, branding: config.http.branding, + lockEnabled: config.lock.enabled, + isLocked: isLocked, + hasAccess: hasAccess }) } else { debug(`Email ${req.params.uid} not found for ${req.params.address}`) @@ -95,6 +123,7 @@ router.get( router.get( '^/:address/delete-all', sanitizeAddress, + checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') @@ -118,6 +147,7 @@ router.get( router.get( '^/:address/:uid/delete', sanitizeAddress, + checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') @@ -136,6 +166,7 @@ router.get( router.get( '^/:address/:uid/:checksum([a-f0-9]+)', sanitizeAddress, + checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') @@ -195,6 +226,7 @@ router.get( router.get( '^/:address/:uid/raw', sanitizeAddress, + checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') diff --git a/infrastructure/web/routes/lock.js b/infrastructure/web/routes/lock.js new file mode 100644 index 0000000..607003c --- /dev/null +++ b/infrastructure/web/routes/lock.js @@ -0,0 +1,112 @@ +const express = require('express') +const router = express.Router() + +router.post('/lock', async(req, res) => { + const { address, password } = req.body + + if (!address || !password || password.length < 8) { + if (req.session) req.session.lockError = 'invalid' + return res.redirect(`/inbox/${address}`) + } + + try { + const inboxLock = req.app.get('inboxLock') + const mailProcessingService = req.app.get('mailProcessingService') + const config = req.app.get('config') + + // Prevent locking the example inbox; allow UI but block DB insert + if (config && config.email && config.email.examples && config.email.examples.account && address.toLowerCase() === config.email.examples.account.toLowerCase()) { + if (req.session) req.session.lockError = 'locking_disabled_for_example' + return res.redirect(`/inbox/${address}`) + } + + await inboxLock.lock(address, password) + + // Clear cache for this inbox + if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { + mailProcessingService.cachedFetchFullMail.clear() + } + + req.session.lockedInbox = address + res.redirect(`/inbox/${address}`) + } catch (error) { + console.error('Lock error:', error) + if (req.session) req.session.lockError = 'server_error' + res.redirect(`/inbox/${address}`) + } +}) + +router.post('/unlock', async(req, res) => { + const { address, password, redirectTo } = req.body + const destination = redirectTo && redirectTo.startsWith('/') ? redirectTo : `/inbox/${address}` + + if (!address || !password) { + 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) { + if (req.session) req.session.unlockError = 'invalid_password' + return res.redirect(destination) + } + + req.session.lockedInbox = address + res.redirect(destination) + } catch (error) { + console.error('Unlock error:', error) + if (req.session) req.session.unlockError = 'server_error' + res.redirect(destination) + } +}) + +router.get('/logout', (req, res) => { + const mailProcessingService = req.app.get('mailProcessingService') + + // Clear cache before logout + if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { + mailProcessingService.cachedFetchFullMail.clear() + } + + req.session.destroy() + res.redirect('/') +}) + +router.post('/remove', async(req, res) => { + const { address } = req.body + + if (!address) { + return res.redirect('/') + } + + // Check if user has access to this locked inbox + const hasAccess = req.session && req.session.lockedInbox === address.toLowerCase() + + if (!hasAccess) { + return res.redirect(`/inbox/${address}`) + } + + try { + const inboxLock = req.app.get('inboxLock') + const mailProcessingService = req.app.get('mailProcessingService') + + await inboxLock.release(address) + + // Clear cache when removing lock + if (mailProcessingService.cachedFetchFullMail && mailProcessingService.cachedFetchFullMail.clear) { + mailProcessingService.cachedFetchFullMail.clear() + } + + req.session.destroy() + res.redirect(`/inbox/${address}`) + } catch (error) { + 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 diff --git a/infrastructure/web/views/error.twig b/infrastructure/web/views/error.twig index f0f218f..06ece55 100644 --- a/infrastructure/web/views/error.twig +++ b/infrastructure/web/views/error.twig @@ -2,8 +2,10 @@ {% block header %}
{% endblock %} @@ -11,4 +13,48 @@{{error.stack}}
+
+ {% if showUnlockButton %}
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig
index 940cdb2..8169708 100644
--- a/infrastructure/web/views/inbox.twig
+++ b/infrastructure/web/views/inbox.twig
@@ -2,14 +2,27 @@
{% block header %}
Your emails will appear here once they arrive.
-+ There are no mails yet. ++ {% endif %} + + {% if lockEnabled and not isLocked %} + + + + {% endif %} + + {% if lockEnabled and isLocked and not hasAccess %} + + + + {% endif %} + + {% if lockEnabled and isLocked and hasAccess %} + + + + {# JS handled in /javascripts/lock-modals.js #} + {% endif %} {% endblock %} diff --git a/infrastructure/web/views/mail.twig b/infrastructure/web/views/mail.twig index 6da233f..99a1be3 100644 --- a/infrastructure/web/views/mail.twig +++ b/infrastructure/web/views/mail.twig @@ -5,7 +5,11 @@ ← Return to inbox Delete Email View Raw - Logout + {% if lockEnabled and isLocked and hasAccess %} + Logout + {% else %} + Logout + {% endif %}