Compare commits

...

8 commits

Author SHA1 Message Date
ClaraCrazy
69011624a7
[Chore]: Updated Docker build
Dockerfile now correctly builds for using locking and compose file uses environment variables instead of mapping the config file into the container
2026-01-03 15:38:23 +01:00
ClaraCrazy
48146fd385
[Chore]: Move schema.sql 2026-01-03 15:29:05 +01:00
ClaraCrazy
ce03710cae
[Chore]: Final Cleanup 2026-01-02 20:57:24 +01:00
ClaraCrazy
8ed7ccade8
[Chore]: Misc changes around user merge
- Update lock removal timer and behaviour
- Redirect to previous path on sign-in and out
- Fix dashbaord UI and other UX elemets
- Lose sanity threlf times
2026-01-02 20:56:14 +01:00
ClaraCrazy
004d764238
[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
2026-01-02 18:49:57 +01:00
ClaraCrazy
598cea9b9c
[Feat]: Add User Registration
Add User table to sql, add user-repository, add registration and login routes, update config
2026-01-02 16:27:43 +01:00
ClaraCrazy
2a08aa14a8
[Feat]: Add email validation function
Currently only used for forwarding
2026-01-02 16:13:22 +01:00
ClaraCrazy
8daa0fefe9
[Feat]: Add email forwarding
Uses seperate SMTP credentials for forwarding. This is just the raw system, validation will be in the next commit.
2026-01-02 16:11:29 +01:00
38 changed files with 4201 additions and 384 deletions

View file

@ -23,8 +23,17 @@ IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interv
IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load
IMAP_CONCURRENCY=6 # Number of concurrent fetch workers during initial load IMAP_CONCURRENCY=6 # Number of concurrent fetch workers during initial load
# --- SMTP CONFIGURATION (for email forwarding) ---
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
SMTP_HOST="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)
SMTP_SECURE=true # Use SSL (true for port 465, false for other ports)
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
SMTP_PASSWORD="password" # SMTP authentication password
# --- HTTP / WEB CONFIGURATION --- # --- HTTP / WEB CONFIGURATION ---
HTTP_PORT=3000 # Port HTTP_PORT=3000 # Port
HTTP_BASE_URL="http://localhost:3000" # Base URL for verification links (e.g., https://48hr.email)
HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url'] HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url']
HTTP_DISPLAY_SORT=2 # Domain display sorting: HTTP_DISPLAY_SORT=2 # Domain display sorting:
# 0 = no change, # 0 = no change,
@ -33,8 +42,11 @@ HTTP_DISPLAY_SORT=2 # Domain display
# 3 = shuffle all # 3 = shuffle all
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
# --- INBOX LOCKING (optional) --- # --- USER AUTHENTICATION & INBOX LOCKING ---
LOCK_ENABLED=false # Enable inbox locking with passwords USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
LOCK_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
LOCK_DATABASE_PATH="./db/locked-inboxes.db" # Path to lock database USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks)
LOCK_RELEASE_HOURS=720 # Auto-release locked inboxes after X hours of inactivity (default 30 days) USER_MAX_FORWARD_EMAILS=5 # Maximum verified forwarding emails per user
USER_MAX_LOCKED_INBOXES=5 # Maximum locked inboxes per user
LOCK_RELEASE_HOURS=168 # Auto-release locked inboxes after X hours without login (default: 168 = 7 days)

BIN
.github/assets/account.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

BIN
.github/assets/home.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 281 KiB

BIN
.github/assets/raw.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

View file

@ -4,5 +4,6 @@ COPY . /home/node/app
RUN chown -R node:node /home/node/app RUN chown -R node:node /home/node/app
WORKDIR /home/node/app WORKDIR /home/node/app
USER node USER node
RUN npm i RUN npm ci
RUN mkdir db
CMD ["npm", "run", "start"] CMD ["npm", "run", "start"]

View file

@ -33,7 +33,7 @@ All data is being removed 48hrs after they have reached the mail server.
- Delete your emails ahead of time by pressing the delete button - Delete your emails ahead of time by pressing the delete button
- View the raw email, showing all the headers etc. - View the raw email, showing all the headers etc.
- Download Attachments with one click - Download Attachments with one click
- Password-protected inboxes - <u>Optional</u> User Account System with email forwarding and inbox locking
- and more... - and more...
<br> <br>
@ -42,13 +42,14 @@ All data is being removed 48hrs after they have reached the mail server.
## Screenshots ## Screenshots
| Inbox | Email using HTML and CSS | | Homepage | Account Panel |
|:---:|:---:| |:---:|:---:|
| <img src=".github/assets/inbox.png" width="500px" height="300px" style="object-fit: cover;"> | <img src=".github/assets/html.png" width="500px" height="300px" style="object-fit: cover;"> | | <img src=".github/assets/home.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/account.png" width="500px" height="300px" style="object-fit: fit;"> |
| Inbox | Email using HTML and CSS | Attachments and Cryptographic Keys view |
|:---:|:---:|:---:|
| <img src=".github/assets/inbox.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/html.png" width="500px" height="300px" style="object-fit: fit;"> | <img src=".github/assets/keys.png" width="500px" height="300px" style="object-fit: fit;"> |
| Email without CSS | Dropdown for cryptographic Keys and Signatures |
|:---:|:---:|
| <img src=".github/assets/raw.png" width="500px" height="300px" style="object-fit: cover;"> | <img src=".github/assets/keys.png" width="500px" height="300px" style="object-fit: cover;"> |
<br> <br>
@ -65,7 +66,7 @@ All data is being removed 48hrs after they have reached the mail server.
## How can I set this up myself? ## How can I set this up myself?
**Prerequisites:** **Prerequisites:**
- Mail server with IMAP - Mail server with IMAP (Optionally also SMTP for registration and protected features)
- One or multiple domains dedicated to this - One or multiple domains dedicated to this
- git & nodejs - git & nodejs
@ -134,15 +135,6 @@ If desired, you can also move the config file somewhere else (change volume moun
----- -----
## TODO (PRs welcome)
- Add user registration:
- Allow people to forward single emails, or an inbox in its current state
<br>
-----
## Support me ## Support me
If you find this project useful, consider supporting its development! If you find this project useful, consider supporting its development!

67
app.js
View file

@ -4,42 +4,87 @@
const config = require('./application/config') const config = require('./application/config')
const debug = require('debug')('48hr-email:app') const debug = require('debug')('48hr-email:app')
const Helper = require('./application/helper')
const { app, io, server } = require('./infrastructure/web/web') const { app, io, server } = require('./infrastructure/web/web')
const ClientNotification = require('./infrastructure/web/client-notification') const ClientNotification = require('./infrastructure/web/client-notification')
const ImapService = require('./application/imap-service') const ImapService = require('./application/imap-service')
const MailProcessingService = require('./application/mail-processing-service') const MailProcessingService = require('./application/mail-processing-service')
const SmtpService = require('./application/smtp-service')
const AuthService = require('./application/auth-service')
const MailRepository = require('./domain/mail-repository') const MailRepository = require('./domain/mail-repository')
const InboxLock = require('./domain/inbox-lock') const InboxLock = require('./domain/inbox-lock')
const VerificationStore = require('./domain/verification-store')
const UserRepository = require('./domain/user-repository')
const clientNotification = new ClientNotification() const clientNotification = new ClientNotification()
debug('Client notification service initialized') debug('Client notification service initialized')
clientNotification.use(io) clientNotification.use(io)
let inboxLock = null const smtpService = new SmtpService(config)
// Initialize inbox locking if enabled debug('SMTP service initialized')
if (config.lock.enabled) { app.set('smtpService', smtpService)
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 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)
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 (users who haven't logged in for 7 days)
setInterval(() => { setInterval(() => {
const inactive = inboxLock.getInactive(config.lock.releaseHours) const inactive = inboxLock.getInactive(config.user.lockReleaseHours)
if (inactive.length > 0) { if (inactive.length > 0) {
console.log(`Releasing ${inactive.length} inactive locked inbox(es)`) debug(`Auto-releasing ${inactive.length} locked inbox(es) due to user inactivity (${config.user.lockReleaseHours} hours without login)`)
inactive.forEach(address => inboxLock.release(address)) inactive.forEach(lock => {
try {
inboxLock.release(lock.userId, lock.address)
debug(`Released lock on ${lock.address} for inactive user ${lock.userId}`)
} catch (error) {
debug(`Failed to release lock on ${lock.address}: ${error.message}`)
}
})
} }
}, config.imap.refreshIntervalSeconds * 1000) }, 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) const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized') debug('IMAP service initialized')
const mailProcessingService = new MailProcessingService( const mailProcessingService = new MailProcessingService(
new MailRepository(), new MailRepository(),
imapService, imapService,
clientNotification, clientNotification,
config config,
smtpService,
verificationStore
) )
debug('Mail processing service initialized') debug('Mail processing service initialized')

246
application/auth-service.js Normal file
View file

@ -0,0 +1,246 @@
const bcrypt = require('bcrypt')
const debug = require('debug')('48hr-email:auth-service')
/**
* Authentication Service - Business logic for user authentication
* Handles registration, login, validation, and password management
*/
class AuthService {
constructor(userRepository, config) {
this.userRepository = userRepository
this.config = config
this.BCRYPT_ROUNDS = 12
}
/**
* Register a new user
* @param {string} username - Username (3-20 alphanumeric + underscore)
* @param {string} password - Password (min 8 chars, complexity requirements)
* @returns {Promise<{success: boolean, user?: Object, error?: string}>}
*/
async register(username, password) {
// Validate username
const usernameValidation = this.validateUsername(username)
if (!usernameValidation.valid) {
debug(`Registration failed: ${usernameValidation.error}`)
return { success: false, error: usernameValidation.error }
}
// Validate password
const passwordValidation = this.validatePassword(password)
if (!passwordValidation.valid) {
debug(`Registration failed: ${passwordValidation.error}`)
return { success: false, error: passwordValidation.error }
}
try {
// Hash password
const passwordHash = await this.hashPassword(password)
// Create user
const user = this.userRepository.createUser(username, passwordHash)
debug(`User registered successfully: ${username} (ID: ${user.id})`)
return {
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
}
} catch (error) {
if (error.message === 'Username already exists') {
debug(`Registration failed: Username already exists: ${username}`)
return { success: false, error: 'Username already exists' }
}
debug(`Registration error: ${error.message}`)
return { success: false, error: 'Registration failed. Please try again.' }
}
}
/**
* Login user with username and password
* @param {string} username
* @param {string} password
* @returns {Promise<{success: boolean, user?: Object, error?: string}>}
*/
async login(username, password) {
if (!username || !password) {
debug('Login failed: Missing username or password')
return { success: false, error: 'Username and password are required' }
}
try {
// Get user from database
const user = this.userRepository.getUserByUsername(username)
if (!user) {
debug(`Login failed: User not found: ${username}`)
// Use generic error to prevent username enumeration
return { success: false, error: 'Invalid username or password' }
}
// Verify password
const isValid = await this.verifyPassword(password, user.password_hash)
if (!isValid) {
debug(`Login failed: Invalid password for user: ${username}`)
return { success: false, error: 'Invalid username or password' }
}
// Update last login
this.userRepository.updateLastLogin(user.id)
debug(`User logged in successfully: ${username} (ID: ${user.id})`)
return {
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: Date.now()
}
}
} catch (error) {
debug(`Login error: ${error.message}`)
return { success: false, error: 'Login failed. Please try again.' }
}
}
/**
* Validate username format
* @param {string} username
* @returns {{valid: boolean, error?: string}}
*/
validateUsername(username) {
if (!username) {
return { valid: false, error: 'Username is required' }
}
if (typeof username !== 'string') {
return { valid: false, error: 'Username must be a string' }
}
const trimmed = username.trim()
if (trimmed.length < 3) {
return { valid: false, error: 'Username must be at least 3 characters' }
}
if (trimmed.length > 20) {
return { valid: false, error: 'Username must be at most 20 characters' }
}
// Only allow alphanumeric and underscore
const usernameRegex = /^[a-zA-Z0-9_]+$/
if (!usernameRegex.test(trimmed)) {
return { valid: false, error: 'Username can only contain letters, numbers, and underscores' }
}
return { valid: true }
}
/**
* Validate password strength
* @param {string} password
* @returns {{valid: boolean, error?: string}}
*/
validatePassword(password) {
if (!password) {
return { valid: false, error: 'Password is required' }
}
if (typeof password !== 'string') {
return { valid: false, error: 'Password must be a string' }
}
if (password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' }
}
if (password.length > 72) {
// Bcrypt max length
return { valid: false, error: 'Password must be at most 72 characters' }
}
// Check for at least one uppercase letter
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' }
}
// Check for at least one lowercase letter
if (!/[a-z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' }
}
// Check for at least one number
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain at least one number' }
}
return { valid: true }
}
/**
* Hash password using bcrypt
* @param {string} password
* @returns {Promise<string>} - Password hash
*/
async hashPassword(password) {
try {
const hash = await bcrypt.hash(password, this.BCRYPT_ROUNDS)
debug('Password hashed successfully')
return hash
} catch (error) {
debug(`Error hashing password: ${error.message}`)
throw new Error('Failed to hash password')
}
}
/**
* Verify password against hash
* @param {string} password
* @param {string} hash
* @returns {Promise<boolean>}
*/
async verifyPassword(password, hash) {
try {
const isValid = await bcrypt.compare(password, hash)
debug(`Password verification: ${isValid ? 'success' : 'failed'}`)
return isValid
} catch (error) {
debug(`Error verifying password: ${error.message}`)
return false
}
}
/**
* Get user for session (without sensitive data)
* @param {number} userId
* @returns {Object|null} - User session data
*/
getUserForSession(userId) {
try {
const user = this.userRepository.getUserById(userId)
if (!user) {
return null
}
return {
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: user.last_login
}
} catch (error) {
debug(`Error getting user for session: ${error.message}`)
return null
}
}
}
module.exports = AuthService

View file

@ -56,18 +56,37 @@ const config = {
fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6 fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6
}, },
smtp: {
enabled: parseBool(process.env.SMTP_ENABLED) || false,
host: parseValue(process.env.SMTP_HOST),
port: Number(process.env.SMTP_PORT) || 465,
secure: parseBool(process.env.SMTP_SECURE) || true,
user: parseValue(process.env.SMTP_USER),
password: parseValue(process.env.SMTP_PASSWORD)
},
http: { http: {
port: Number(process.env.HTTP_PORT), port: Number(process.env.HTTP_PORT),
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
branding: parseValue(process.env.HTTP_BRANDING), branding: parseValue(process.env.HTTP_BRANDING),
displaySort: Number(process.env.HTTP_DISPLAY_SORT), displaySort: Number(process.env.HTTP_DISPLAY_SORT),
hideOther: parseBool(process.env.HTTP_HIDE_OTHER) hideOther: parseBool(process.env.HTTP_HIDE_OTHER)
}, },
lock: { user: {
enabled: parseBool(process.env.LOCK_ENABLED) || false, // Authentication System
sessionSecret: parseValue(process.env.LOCK_SESSION_SECRET) || 'change-me-in-production', authEnabled: parseBool(process.env.USER_AUTH_ENABLED) || false,
dbPath: parseValue(process.env.LOCK_DATABASE_PATH) || './db/locked-inboxes.db',
releaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 720 // 30 days default // Database
databasePath: parseValue(process.env.USER_DATABASE_PATH) || './db/data.db',
// Session & Auth
sessionSecret: parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production',
// Feature Limits
maxForwardEmails: Number(process.env.USER_MAX_FORWARD_EMAILS) || 5,
maxLockedInboxes: Number(process.env.USER_MAX_LOCKED_INBOXES) || 5,
lockReleaseHours: Number(process.env.LOCK_RELEASE_HOURS) || 168 // 7 days default
} }
}; };

View file

@ -1,5 +1,6 @@
const config = require('./config') const config = require('./config')
const debug = require('debug')('48hr-email:helper') const debug = require('debug')('48hr-email:helper')
const crypto = require('crypto')
class Helper { class Helper {
@ -180,6 +181,87 @@ class Helper {
</label>` </label>`
return handling return handling
} }
/**
* Generate a cryptographically secure random verification token
* @returns {string} - 32-byte hex token (64 characters)
*/
generateVerificationToken() {
const token = crypto.randomBytes(32).toString('hex')
debug('Generated verification token')
return token
}
/**
* Sign an email address for use in a cookie
* Uses HMAC-SHA256 with the session secret
* @param {string} email - Email address to sign
* @returns {string} - HMAC signature (hex)
*/
signCookie(email) {
const secret = config.user.sessionSecret
const hmac = crypto.createHmac('sha256', secret)
hmac.update(email.toLowerCase())
const signature = hmac.digest('hex')
debug(`Signed cookie for email: ${email}`)
return signature
}
/**
* Verify a cookie signature for an email address
* @param {string} email - Email address to verify
* @param {string} signature - HMAC signature to verify
* @returns {boolean} - True if signature is valid
*/
verifyCookieSignature(email, signature) {
if (!email || !signature) {
return false
}
const expectedSignature = this.signCookie(email)
// Use timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
)
} catch (error) {
// timingSafeEqual throws if buffers are different lengths
debug(`Cookie signature verification failed: ${error.message}`)
return false
}
}
/**
* Migrate legacy database files for backwards compatibility
* - Renames users.db to data.db if it exists
* - Logs if locked-inboxes.db exists (no longer needed)
* @param {string} dbPath - Path to the current database (data.db)
*/
static migrateDatabase(dbPath) {
const fs = require('fs')
const path = require('path')
const dbDir = path.dirname(dbPath)
const legacyUsersDb = path.join(dbDir, 'users.db')
const legacyLockedInboxesDb = path.join(dbDir, 'locked-inboxes.db')
// Migrate users.db to data.db
if (fs.existsSync(legacyUsersDb) && !fs.existsSync(dbPath)) {
console.log(`Migrating ${legacyUsersDb}${dbPath}`)
fs.renameSync(legacyUsersDb, dbPath)
debug(`Database migrated: users.db → data.db`)
}
// Warn about old locked-inboxes.db
if (fs.existsSync(legacyLockedInboxesDb)) {
console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`)
console.log(` This database is no longer used. Locks are now stored in ${path.basename(dbPath)}.`)
console.log(` You can safely delete ${legacyLockedInboxesDb} after verifying your locks are working.`)
debug('Legacy locked-inboxes.db detected but not migrated (data already in user_locked_inboxes table)')
}
}
} }
module.exports = Helper module.exports = Helper

View file

@ -7,12 +7,15 @@ const helper = new(Helper)
class MailProcessingService extends EventEmitter { class MailProcessingService extends EventEmitter {
constructor(mailRepository, imapService, clientNotification, config) { constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null) {
super() super()
this.mailRepository = mailRepository this.mailRepository = mailRepository
this.clientNotification = clientNotification this.clientNotification = clientNotification
this.imapService = imapService this.imapService = imapService
this.config = config this.config = config
this.smtpService = smtpService
this.verificationStore = verificationStore
this.helper = new(Helper)
// Cached methods: // Cached methods:
this._initCache() this._initCache()
@ -214,6 +217,157 @@ class MailProcessingService extends EventEmitter {
} }
} }
/**
* Forward an email to a destination address
* @param {string} address - The recipient address of the email to forward
* @param {number|string} uid - The UID of the email to forward
* @param {string} destinationEmail - The email address to forward to
* @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
*/
async forwardEmail(address, uid, destinationEmail) {
// Check if SMTP service is available
if (!this.smtpService) {
debug('Forward attempt failed: SMTP service not configured')
return {
success: false,
error: 'Email forwarding is not configured. Please configure SMTP settings.'
}
}
// Check if email exists in repository
const mailSummary = this.mailRepository.getForRecipient(address)
.find(mail => parseInt(mail.uid) === parseInt(uid))
if (!mailSummary) {
debug(`Forward attempt failed: Email not found (address: ${address}, uid: ${uid})`)
return {
success: false,
error: 'Email not found'
}
}
try {
// Fetch full email content using cached method
debug(`Fetching full email for forwarding (address: ${address}, uid: ${uid})`)
const fullMail = await this.getOneFullMail(address, uid, false)
if (!fullMail) {
debug('Forward attempt failed: Could not fetch full email')
return {
success: false,
error: 'Could not retrieve email content'
}
}
// Forward via SMTP service
debug(`Forwarding email to ${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}`)
} else {
debug(`Email forwarding failed: ${result.error}`)
}
return result
} catch (error) {
debug('Error forwarding email:', error.message)
return {
success: false,
error: `Failed to forward email: ${error.message}`
}
}
}
/**
* Initiate email verification for forwarding
* Sends verification email to destination address
* @param {string} sourceAddress - The inbox address requesting forwarding
* @param {string} destinationEmail - The email address to verify and forward to
* @param {Array<number>} uids - Array of email UIDs to forward (optional, for context)
* @returns {Promise<{success: boolean, error?: string, cooldownSeconds?: number}>}
*/
async initiateForwardVerification(sourceAddress, destinationEmail, uids = []) {
// Check if verification store is available
if (!this.verificationStore) {
debug('Verification store not available')
return {
success: false,
error: 'Email verification is not configured'
}
}
// Check if SMTP service is available
if (!this.smtpService) {
debug('SMTP service not configured')
return {
success: false,
error: 'Email forwarding is not configured. Please configure SMTP settings.'
}
}
// Check rate limit (5-minute cooldown)
const canRequest = this.verificationStore.canRequestVerification(destinationEmail)
if (!canRequest) {
const lastRequest = this.verificationStore.getLastVerificationTime(destinationEmail)
const cooldownMs = 5 * 60 * 1000
const elapsed = Date.now() - lastRequest
const remainingSeconds = Math.ceil((cooldownMs - elapsed) / 1000)
debug(`Verification rate limit hit for ${destinationEmail}, ${remainingSeconds}s remaining`)
return {
success: false,
error: `Please wait ${remainingSeconds} seconds before requesting another verification email`,
cooldownSeconds: remainingSeconds
}
}
try {
// Generate verification token
const token = this.helper.generateVerificationToken()
// Store verification with metadata
this.verificationStore.createVerification(token, destinationEmail, {
sourceAddress,
uids,
createdAt: new Date().toISOString()
})
// Send verification email
const baseUrl = this.config.http.baseUrl
const branding = this.config.http.branding[0] || '48hr.email'
debug(`Sending verification email to ${destinationEmail} for source ${sourceAddress}`)
const result = await this.smtpService.sendVerificationEmail(
destinationEmail,
token,
baseUrl,
branding
)
if (result.success) {
debug(`Verification email sent successfully. MessageId: ${result.messageId}`)
return {
success: true,
messageId: result.messageId
}
} else {
debug(`Failed to send verification email: ${result.error}`)
return {
success: false,
error: result.error
}
}
} catch (error) {
debug('Error initiating verification:', error.message)
return {
success: false,
error: `Failed to send verification email: ${error.message}`
}
}
}
_saveToFile(mails, filename) { _saveToFile(mails, filename) {
const fs = require('fs') const fs = require('fs')
fs.writeFile(filename, JSON.stringify(mails), err => { fs.writeFile(filename, JSON.stringify(mails), err => {

329
application/smtp-service.js Normal file
View file

@ -0,0 +1,329 @@
const nodemailer = require('nodemailer')
const debug = require('debug')('48hr-email:smtp-service')
/**
* SMTP Service for forwarding emails
* Uses nodemailer to send forwarded emails via configured SMTP server
*/
class SmtpService {
constructor(config) {
this.config = config
this.transporter = null
// Only initialize transporter if SMTP is configured
if (this._isConfigured()) {
this._initializeTransporter()
} else {
debug('SMTP not configured - forwarding functionality will be unavailable')
}
}
/**
* Check if SMTP is properly configured
* @returns {boolean}
*/
_isConfigured() {
return !!(
this.config.smtp.enabled &&
this.config.smtp.host &&
this.config.smtp.user &&
this.config.smtp.password
)
}
/**
* Initialize the nodemailer transporter
* @private
*/
_initializeTransporter() {
try {
this.transporter = nodemailer.createTransport({
host: this.config.smtp.host,
port: this.config.smtp.port,
secure: this.config.smtp.secure,
auth: {
user: this.config.smtp.user,
pass: this.config.smtp.password
},
tls: {
// Allow self-signed certificates and skip verification
// This is useful for development or internal SMTP servers
rejectUnauthorized: false
}
})
debug(`SMTP transporter initialized: ${this.config.smtp.host}:${this.config.smtp.port}`)
} catch (error) {
debug('Failed to initialize SMTP transporter:', error.message)
throw new Error(`SMTP initialization failed: ${error.message}`)
}
}
/**
* Forward an email to a destination address
* @param {Object} mail - Parsed email object from mailparser
* @param {string} destinationEmail - Email address to forward to
* @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
*/
async forwardMail(mail, destinationEmail, branding = '48hr.email') {
if (!this.transporter) {
return {
success: false,
error: 'SMTP is not configured. Please configure SMTP settings to enable forwarding.'
}
}
if (!mail) {
return {
success: false,
error: 'Email not found'
}
}
try {
debug(`Forwarding email (Subject: "${mail.subject}") to ${destinationEmail}`)
const forwardMessage = this._buildForwardMessage(mail, destinationEmail, branding)
const info = await this.transporter.sendMail(forwardMessage)
debug(`Email forwarded successfully. MessageId: ${info.messageId}`)
return {
success: true,
messageId: info.messageId
}
} catch (error) {
debug('Failed to forward email:', error.message)
return {
success: false,
error: `Failed to send email: ${error.message}`
}
}
}
/**
* 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, 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'
const originalDate = mail.date ? new Date(mail.date).toLocaleString() : 'Unknown Date'
const originalSubject = mail.subject || '(no subject)'
// Build forwarded message body
let forwardedBody = `
---------- Forwarded message ----------
From: ${originalFrom}
Date: ${originalDate}
Subject: ${originalSubject}
To: ${originalTo}
`
// Add original text body if available
if (mail.text) {
forwardedBody += mail.text
} else if (mail.html) {
// If only HTML is available, mention it
forwardedBody += '[This email contains HTML content. See attachment or HTML version below.]\n\n'
}
// Build the message object
const message = {
from: {
name: branding,
address: this.config.smtp.user
},
to: destinationEmail,
subject: `Fwd: ${originalSubject}`,
text: forwardedBody,
replyTo: originalFrom
}
// Add HTML body if available
if (mail.html) {
const htmlForwardedBody = `
<div style="border-left: 2px solid #ccc; padding-left: 10px; margin: 10px 0;">
<p><strong>---------- Forwarded message ----------</strong><br>
<strong>From:</strong> ${this._escapeHtml(originalFrom)}<br>
<strong>Date:</strong> ${this._escapeHtml(originalDate)}<br>
<strong>Subject:</strong> ${this._escapeHtml(originalSubject)}<br>
<strong>To:</strong> ${this._escapeHtml(originalTo)}</p>
</div>
${mail.html}
`
message.html = htmlForwardedBody
}
// Add attachments if present
if (mail.attachments && mail.attachments.length > 0) {
message.attachments = mail.attachments.map(att => ({
filename: att.filename || 'attachment',
content: att.content,
contentType: att.contentType,
contentDisposition: att.contentDisposition || 'attachment'
}))
debug(`Including ${mail.attachments.length} attachment(s) in forwarded email`)
}
return message
}
/**
* Simple HTML escape for email headers
* @param {string} text
* @returns {string}
* @private
*/
_escapeHtml(text) {
if (!text) return ''
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* Verify SMTP connection
* @returns {Promise<{success: boolean, error?: string}>}
*/
async verifyConnection() {
if (!this.transporter) {
return {
success: false,
error: 'SMTP is not configured'
}
}
try {
await this.transporter.verify()
debug('SMTP connection verified successfully')
return { success: true }
} catch (error) {
debug('SMTP connection verification failed:', error.message)
return {
success: false,
error: error.message
}
}
}
/**
* Send verification email to destination address
* @param {string} destinationEmail - Email address to verify
* @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', verifyPath = '/inbox/verify') {
if (!this.transporter) {
return {
success: false,
error: 'SMTP is not configured. Please configure SMTP settings to enable forwarding.'
}
}
const verificationLink = `${baseUrl}${verifyPath}?token=${token}`
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
.content { background: #f9f9f9; padding: 30px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 5px 5px; }
.button { display: inline-block; background: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; font-weight: bold; }
.button:hover { background: #2980b9; }
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 20px 0; }
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }
code { background: #e8e8e8; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
</style>
</head>
<body>
<div class="header">
<h2>🔐 Verify Your Email Address</h2>
</div>
<div class="content">
<p>Hello,</p>
<p>You requested to use <strong>${this._escapeHtml(destinationEmail)}</strong> as a forwarding destination on <strong>${this._escapeHtml(branding)}</strong>.</p>
<p>To verify ownership of this email address and enable forwarding for 24 hours, please click the button below:</p>
<div style="text-align: center;">
<a href="${verificationLink}" class="button">Verify Email Address</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p><code>${verificationLink}</code></p>
<div class="warning">
<strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
</div>
<p>If you didn't request this verification, you can safely ignore this email.</p>
</div>
<div class="footer">
<p>This is an automated message from ${this._escapeHtml(branding)}</p>
</div>
</body>
</html>
`
const textContent = `
Verify Your Email Address
You requested to use ${destinationEmail} as a forwarding destination on ${branding}.
To verify ownership of this email address and enable forwarding for 24 hours, please visit:
${verificationLink}
IMPORTANT: This verification link expires in 15 minutes. Once verified, you'll be able to forward emails to this address for 24 hours.
If you didn't request this verification, you can safely ignore this email.
---
This is an automated message from ${branding}
`
try {
const info = await this.transporter.sendMail({
from: `"${branding} Forwarding Service" <${this.config.smtp.user}>`,
to: destinationEmail,
subject: `${branding} - Verify your email for forwarding`,
text: textContent,
html: htmlContent
})
debug(`Verification email sent to ${destinationEmail}, messageId: ${info.messageId}`)
return {
success: true,
messageId: info.messageId
}
} catch (error) {
debug(`Failed to send verification email: ${error.message}`)
return {
success: false,
error: `Failed to send verification email: ${error.message}`
}
}
}
}
module.exports = SmtpService

View file

@ -1,13 +1,15 @@
services: services:
48hr: 48hr:
## Map environment variables from file (e.g. for imap password) ## Map environment variables from file (e.g. for imap password)
#env_file: ./.env env_file: ./.env
image: localhost/48hr image: localhost/48hr
restart: always restart: always
build: . build: .
ports: ports:
- 3000:3000 - 3000:3000
## Map config file from project to docker container ## Create volume for inbox locking
volumes: #volumes:
- ./application/config.js:/home/node/app/application/config.js # - 48hr-vol:/home/node/app/db/
#volumes:
# 48hr-vol:

View file

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

368
domain/user-repository.js Normal file
View file

@ -0,0 +1,368 @@
const Database = require('better-sqlite3')
const debug = require('debug')('48hr-email:user-repository')
const fs = require('fs')
const path = require('path')
/**
* User Repository - Data access layer for user accounts
* Manages users, their verified forwarding emails, and locked inboxes
*/
class UserRepository {
constructor(dbPath) {
this.dbPath = dbPath
this.db = null
this._initialize()
}
/**
* Initialize database connection and create schema
* @private
*/
_initialize() {
try {
// Ensure directory exists
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
debug(`Created database directory: ${dbDir}`)
}
// Open database connection
this.db = new Database(this.dbPath)
this.db.pragma('journal_mode = WAL')
debug(`Connected to user database: ${this.dbPath}`)
// Load and execute schema
const schemaPath = path.join(__dirname, '../schema.sql')
const schema = fs.readFileSync(schemaPath, 'utf8')
this.db.exec(schema)
debug('Database schema initialized')
} catch (error) {
console.error('Failed to initialize user database:', error)
throw error
}
}
/**
* Create a new user
* @param {string} username - Unique username (3-20 chars)
* @param {string} passwordHash - Bcrypt password hash
* @returns {Object} - Created user object {id, username, created_at}
*/
createUser(username, passwordHash) {
try {
const now = Date.now()
const stmt = this.db.prepare(`
INSERT INTO users (username, password_hash, created_at)
VALUES (?, ?, ?)
`)
const result = stmt.run(username.toLowerCase(), passwordHash, now)
debug(`User created: ${username} (ID: ${result.lastInsertRowid})`)
return {
id: result.lastInsertRowid,
username: username.toLowerCase(),
created_at: now
}
} catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
debug(`Username already exists: ${username}`)
throw new Error('Username already exists')
}
debug(`Error creating user: ${error.message}`)
throw error
}
}
/**
* Get user by username
* @param {string} username
* @returns {Object|null} - User object or null if not found
*/
getUserByUsername(username) {
try {
const stmt = this.db.prepare(`
SELECT id, username, password_hash, created_at, last_login
FROM users
WHERE username = ?
`)
const user = stmt.get(username.toLowerCase())
if (user) {
debug(`User found: ${username} (ID: ${user.id})`)
} else {
debug(`User not found: ${username}`)
}
return user || null
} catch (error) {
debug(`Error getting user by username: ${error.message}`)
throw error
}
}
/**
* Get user by ID
* @param {number} userId
* @returns {Object|null} - User object or null if not found
*/
getUserById(userId) {
try {
const stmt = this.db.prepare(`
SELECT id, username, password_hash, created_at, last_login
FROM users
WHERE id = ?
`)
const user = stmt.get(userId)
if (user) {
debug(`User found by ID: ${userId}`)
} else {
debug(`User not found by ID: ${userId}`)
}
return user || null
} catch (error) {
debug(`Error getting user by ID: ${error.message}`)
throw error
}
}
/**
* Update user's last login timestamp
* @param {number} userId
*/
updateLastLogin(userId) {
try {
const now = Date.now()
const stmt = this.db.prepare(`
UPDATE users
SET last_login = ?
WHERE id = ?
`)
stmt.run(now, userId)
debug(`Updated last login for user ID: ${userId}`)
} catch (error) {
debug(`Error updating last login: ${error.message}`)
throw error
}
}
/**
* Add a verified forwarding email for a user
* @param {number} userId
* @param {string} email - Verified email address
* @returns {Object} - Created forward email entry
*/
addForwardEmail(userId, email) {
try {
const now = Date.now()
const stmt = this.db.prepare(`
INSERT INTO user_forward_emails (user_id, email, verified_at, created_at)
VALUES (?, ?, ?, ?)
`)
const result = stmt.run(userId, email.toLowerCase(), now, now)
debug(`Forward email added for user ${userId}: ${email}`)
return {
id: result.lastInsertRowid,
user_id: userId,
email: email.toLowerCase(),
verified_at: now,
created_at: now
}
} catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
debug(`Forward email already exists for user ${userId}: ${email}`)
throw new Error('Email already added to your account')
}
debug(`Error adding forward email: ${error.message}`)
throw error
}
}
/**
* Get all verified forwarding emails for a user
* @param {number} userId
* @returns {Array} - Array of email objects with formatted timestamps
*/
getForwardEmails(userId) {
try {
const stmt = this.db.prepare(`
SELECT id, email, verified_at, created_at
FROM user_forward_emails
WHERE user_id = ?
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 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
* @param {string} email
* @returns {boolean}
*/
hasForwardEmail(userId, email) {
try {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM user_forward_emails
WHERE user_id = ? AND email = ?
`)
const result = stmt.get(userId, email.toLowerCase())
return result.count > 0
} catch (error) {
debug(`Error checking forward email: ${error.message}`)
throw error
}
}
/**
* Remove a forwarding email from user's account
* @param {number} userId
* @param {string} email
* @returns {boolean} - True if deleted, false if not found
*/
removeForwardEmail(userId, email) {
try {
const stmt = this.db.prepare(`
DELETE FROM user_forward_emails
WHERE user_id = ? AND email = ?
`)
const result = stmt.run(userId, email.toLowerCase())
if (result.changes > 0) {
debug(`Forward email removed for user ${userId}: ${email}`)
return true
} else {
debug(`Forward email not found for user ${userId}: ${email}`)
return false
}
} catch (error) {
debug(`Error removing forward email: ${error.message}`)
throw error
}
}
/**
* Get count of user's forwarding emails
* @param {number} userId
* @returns {number}
*/
getForwardEmailCount(userId) {
try {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM user_forward_emails
WHERE user_id = ?
`)
const result = stmt.get(userId)
return result.count
} catch (error) {
debug(`Error getting forward email count: ${error.message}`)
throw error
}
}
/**
* Get user statistics
* @param {number} userId
* @param {Object} config - Application configuration
* @returns {Object} - {lockedInboxesCount, forwardEmailsCount, accountAge, maxLockedInboxes, maxForwardEmails, lockReleaseHours}
*/
getUserStats(userId, config = {}) {
try {
const user = this.getUserById(userId)
if (!user) {
return null
}
const lockedInboxesStmt = this.db.prepare(`
SELECT COUNT(*) as count FROM user_locked_inboxes WHERE user_id = ?
`)
const forwardEmailsStmt = this.db.prepare(`
SELECT COUNT(*) as count FROM user_forward_emails WHERE user_id = ?
`)
const lockedInboxesCount = lockedInboxesStmt.get(userId).count
const forwardEmailsCount = forwardEmailsStmt.get(userId).count
const accountAgeMs = Date.now() - user.created_at
const accountAge = this._formatAccountAge(accountAgeMs)
debug(`Stats for user ${userId}: ${lockedInboxesCount} locked inboxes, ${forwardEmailsCount} forward emails`)
return {
lockedInboxesCount,
forwardEmailsCount,
accountAge,
createdAt: user.created_at,
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}`)
throw error
}
}
/**
* Format account age in human-readable format
* @param {number} ms - Milliseconds since account creation
* @returns {string} - Formatted age
* @private
*/
_formatAccountAge(ms) {
const days = Math.floor(ms / (1000 * 60 * 60 * 24))
if (days === 0) return 'Today'
if (days === 1) return '1 day'
if (days < 30) return `${days} days`
if (days < 365) return `${Math.floor(days / 30)} months`
return `${Math.floor(days / 365)} years`
}
/**
* Close database connection
*/
close() {
if (this.db) {
this.db.close()
debug('Database connection closed')
}
}
}
module.exports = UserRepository

View file

@ -0,0 +1,161 @@
const debug = require('debug')('48hr-email:verification-store')
/**
* In-memory store for email verification tokens
* Manages pending verifications with expiration and rate limiting
*/
class VerificationStore {
constructor() {
// Map of token -> verification data
this.verifications = new Map()
// Map of destinationEmail -> last verification request timestamp
this.lastVerificationRequests = new Map()
// Cleanup expired tokens every 5 minutes
this.cleanupInterval = setInterval(() => {
this.cleanup()
}, 5 * 60 * 1000)
debug('VerificationStore initialized')
}
/**
* Create a new verification entry
* @param {string} token - Unique verification token
* @param {string} destinationEmail - Email address being verified
* @param {Object} metadata - Additional data (sourceAddress, uids, etc.)
* @returns {Object} - Verification entry
*/
createVerification(token, destinationEmail, metadata = {}) {
const now = Date.now()
const expiresAt = now + (15 * 60 * 1000) // 15 minutes
const verification = {
token,
destinationEmail: destinationEmail.toLowerCase(),
createdAt: now,
expiresAt,
metadata
}
this.verifications.set(token, verification)
this.lastVerificationRequests.set(destinationEmail.toLowerCase(), now)
debug(`Created verification for ${destinationEmail}, token expires in 15 minutes`)
return verification
}
/**
* Verify a token and return the verification data if valid
* @param {string} token - Token to verify
* @returns {Object|null} - Verification data or null if invalid/expired
*/
verifyToken(token) {
const verification = this.verifications.get(token)
if (!verification) {
debug(`Token not found: ${token}`)
return null
}
const now = Date.now()
if (now > verification.expiresAt) {
debug(`Token expired: ${token}`)
this.verifications.delete(token)
return null
}
debug(`Token verified successfully for ${verification.destinationEmail}`)
// Remove token after successful verification (one-time use)
this.verifications.delete(token)
return verification
}
/**
* Get the last verification request time for an email
* @param {string} destinationEmail - Email address to check
* @returns {number|null} - Timestamp of last request or null
*/
getLastVerificationTime(destinationEmail) {
return this.lastVerificationRequests.get(destinationEmail.toLowerCase()) || null
}
/**
* Check if enough time has passed since last verification request
* @param {string} destinationEmail - Email address to check
* @param {number} cooldownMs - Cooldown period in milliseconds (default: 5 minutes)
* @returns {boolean} - True if can request verification, false if still in cooldown
*/
canRequestVerification(destinationEmail, cooldownMs = 5 * 60 * 1000) {
const lastRequest = this.getLastVerificationTime(destinationEmail)
if (!lastRequest) {
return true
}
const now = Date.now()
const timeSinceLastRequest = now - lastRequest
const canRequest = timeSinceLastRequest >= cooldownMs
if (!canRequest) {
const remainingSeconds = Math.ceil((cooldownMs - timeSinceLastRequest) / 1000)
debug(`Verification cooldown active for ${destinationEmail}, ${remainingSeconds}s remaining`)
}
return canRequest
}
/**
* Clean up expired tokens and old rate limit entries
*/
cleanup() {
const now = Date.now()
let expiredCount = 0
let rateLimitCleanupCount = 0
// Clean expired tokens
for (const [token, verification] of this.verifications.entries()) {
if (now > verification.expiresAt) {
this.verifications.delete(token)
expiredCount++
}
}
// Clean old rate limit entries (older than 1 hour)
for (const [email, timestamp] of this.lastVerificationRequests.entries()) {
if (now - timestamp > 60 * 60 * 1000) {
this.lastVerificationRequests.delete(email)
rateLimitCleanupCount++
}
}
if (expiredCount > 0 || rateLimitCleanupCount > 0) {
debug(`Cleanup: removed ${expiredCount} expired tokens, ${rateLimitCleanupCount} old rate limit entries`)
}
}
/**
* Get statistics about the store
* @returns {Object} - Store statistics
*/
getStats() {
return {
pendingVerifications: this.verifications.size,
rateLimitEntries: this.lastVerificationRequests.size
}
}
/**
* Destroy the store and cleanup interval
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
}
this.verifications.clear()
this.lastVerificationRequests.clear()
debug('VerificationStore destroyed')
}
}
module.exports = VerificationStore

View file

@ -0,0 +1,122 @@
const debug = require('debug')('48hr-email:auth-middleware')
/**
* Authentication middleware functions
* Handle session-based authentication and authorization
*/
/**
* Require authenticated user - redirect to login if not authenticated
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function requireAuth(req, res, next) {
if (req.session && req.session.userId && req.session.isAuthenticated) {
// User is authenticated
debug(`Authenticated request from user ${req.session.username} (ID: ${req.session.userId})`)
// Populate req.user for convenience
req.user = {
id: req.session.userId,
username: req.session.username,
created_at: req.session.createdAt
}
return next()
}
// User is not authenticated
debug('Unauthenticated request, redirecting to auth page')
// Store the original URL to redirect back after login
req.session.redirectAfterLogin = req.originalUrl
// Redirect to auth page
return res.redirect('/auth')
}
/**
* Optional authentication - populate req.user if authenticated, but don't redirect
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function optionalAuth(req, res, next) {
if (req.session && req.session.userId && req.session.isAuthenticated) {
// User is authenticated, populate req.user
debug(`Optional auth: User ${req.session.username} (ID: ${req.session.userId}) is authenticated`)
req.user = {
id: req.session.userId,
username: req.session.username,
created_at: req.session.createdAt
}
} else {
// User is not authenticated, set req.user to null
req.user = null
debug('Optional auth: User not authenticated')
}
next()
}
/**
* Check if user owns a specific locked inbox
* Used to verify user can access/modify a locked inbox
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function checkUserOwnsInbox(req, res, next) {
if (!req.user) {
debug('Check inbox ownership: User not authenticated')
return res.status(401).json({ error: 'Authentication required' })
}
const inboxAddress = req.params.address
if (!inboxAddress) {
debug('Check inbox ownership: No inbox address provided')
return res.status(400).json({ error: 'Inbox address required' })
}
// Get user repository from app
const userRepository = req.app.get('userRepository')
if (!userRepository) {
debug('Check inbox ownership: User repository not available')
return res.status(500).json({ error: 'Service not available' })
}
try {
// Check if inbox is in user's locked inboxes
// This will be implemented when we migrate inbox-lock.js
// For now, we'll trust the session
debug(`Check inbox ownership: User ${req.user.username} accessing inbox ${inboxAddress}`)
next()
} catch (error) {
debug(`Check inbox ownership error: ${error.message}`)
return res.status(500).json({ error: 'Failed to verify inbox ownership' })
}
}
/**
* Middleware to prevent access for authenticated users (e.g., login/register pages)
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next function
*/
function redirectIfAuthenticated(req, res, next) {
if (req.session && req.session.userId && req.session.isAuthenticated) {
debug(`User ${req.session.username} already authenticated, redirecting to home`)
return res.redirect('/')
}
next()
}
module.exports = {
requireAuth,
optionalAuth,
checkUserOwnsInbox,
redirectIfAuthenticated
}

View file

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

View file

@ -22,4 +22,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initRefreshCountdown === 'function' && refreshInterval) { if (window.utils && typeof window.utils.initRefreshCountdown === 'function' && refreshInterval) {
window.utils.initRefreshCountdown(refreshInterval); window.utils.initRefreshCountdown(refreshInterval);
} }
// Initialize forward all modal
if (window.utils && typeof window.utils.initForwardAllModal === 'function') {
window.utils.initForwardAllModal();
}
}); });

View file

@ -114,10 +114,6 @@ document.addEventListener('DOMContentLoaded', () => {
const closeLock = document.getElementById('closeLock'); const closeLock = document.getElementById('closeLock');
const lockForm = document.querySelector('#lockModal form'); const lockForm = document.querySelector('#lockModal form');
const unlockModal = document.getElementById('unlockModal');
const unlockBtn = document.getElementById('unlockBtn');
const closeUnlock = document.getElementById('closeUnlock');
const removeLockModal = document.getElementById('removeLockModal'); const removeLockModal = document.getElementById('removeLockModal');
const removeLockBtn = document.getElementById('removeLockBtn'); const removeLockBtn = document.getElementById('removeLockBtn');
const closeRemoveLock = document.getElementById('closeRemoveLock'); const closeRemoveLock = document.getElementById('closeRemoveLock');
@ -136,35 +132,7 @@ document.addEventListener('DOMContentLoaded', () => {
closeLock.onclick = function() { closeModal(lockModal); }; closeLock.onclick = function() { closeModal(lockModal); };
} }
if (lockForm) { // Lock form no longer needs password validation - authentication-based locking
lockForm.addEventListener('submit', function(e) {
const pwElement = document.getElementById('lockPassword');
const cfElement = document.getElementById('lockConfirm');
const pw = pwElement ? pwElement.value : '';
const cf = cfElement ? cfElement.value : '';
const err = document.getElementById('lockErrorInline');
const serverErr = document.getElementById('lockServerError');
if (serverErr) serverErr.style.display = 'none';
if (pw !== cf) {
e.preventDefault();
if (err) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
}
return;
}
if (pw.length < 8) {
e.preventDefault();
if (err) {
err.textContent = 'Password must be at least 8 characters.';
err.style.display = 'block';
}
return;
}
if (err) err.style.display = 'none';
});
}
if (lockModal) { if (lockModal) {
const lockErrorValue = (lockModal.dataset.lockError || '').trim(); const lockErrorValue = (lockModal.dataset.lockError || '').trim();
@ -176,8 +144,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (err) { if (err) {
if (lockErrorValue === 'locking_disabled_for_example') { if (lockErrorValue === 'locking_disabled_for_example') {
err.textContent = 'Locking is disabled for the example inbox.'; err.textContent = 'Locking is disabled for the example inbox.';
} else if (lockErrorValue === 'invalid_password') { } else if (lockErrorValue === 'max_locked_inboxes') {
err.textContent = 'Please provide a valid password.'; err.textContent = 'You have reached the maximum of 5 locked inboxes.';
} else if (lockErrorValue === 'already_locked') {
err.textContent = 'This inbox is already locked by another user.';
} else if (lockErrorValue === 'server_error') { } else if (lockErrorValue === 'server_error') {
err.textContent = 'A server error occurred. Please try again.'; err.textContent = 'A server error occurred. Please try again.';
} else if (lockErrorValue === 'remove_failed') { } else if (lockErrorValue === 'remove_failed') {
@ -190,20 +160,6 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
if (unlockBtn) {
unlockBtn.onclick = function(e) {
e.preventDefault();
openModal(unlockModal);
};
}
if (closeUnlock) {
closeUnlock.onclick = function() { closeModal(unlockModal); };
}
if (unlockModal) {
const unlockErrorValue = (unlockModal.dataset.unlockError || '').trim();
if (unlockErrorValue) { openModal(unlockModal); }
}
if (removeLockBtn) { if (removeLockBtn) {
removeLockBtn.onclick = function(e) { removeLockBtn.onclick = function(e) {
e.preventDefault(); e.preventDefault();
@ -219,7 +175,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.onclick = function(e) { window.onclick = function(e) {
if (e.target === lockModal) closeModal(lockModal); if (e.target === lockModal) closeModal(lockModal);
if (e.target === unlockModal) closeModal(unlockModal);
if (e.target === removeLockModal) closeModal(removeLockModal); if (e.target === removeLockModal) closeModal(removeLockModal);
}; };
} }
@ -361,6 +316,173 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
function initForwardModal() {
const forwardModal = document.getElementById('forwardModal');
const forwardBtn = document.getElementById('forwardBtn');
const closeForward = document.getElementById('closeForward');
const forwardForm = document.querySelector('#forwardModal form');
const forwardEmail = document.getElementById('forwardEmail');
const forwardError = document.getElementById('forwardError');
if (!forwardModal || !forwardBtn) return;
const openModal = (m) => { if (m) m.style.display = 'block'; };
const closeModal = (m) => { if (m) m.style.display = 'none'; };
forwardBtn.onclick = function(e) {
e.preventDefault();
openModal(forwardModal);
if (forwardEmail) forwardEmail.focus();
};
if (closeForward) {
closeForward.onclick = function() {
closeModal(forwardModal);
if (forwardError) forwardError.style.display = 'none';
};
}
if (forwardForm) {
forwardForm.addEventListener('submit', function(e) {
const email = forwardEmail ? forwardEmail.value.trim() : '';
// Basic client-side validation
if (!email) {
e.preventDefault();
if (forwardError) {
forwardError.textContent = 'Please enter an email address.';
forwardError.style.display = 'block';
}
return;
}
// Simple email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
e.preventDefault();
if (forwardError) {
forwardError.textContent = 'Please enter a valid email address.';
forwardError.style.display = 'block';
}
return;
}
if (forwardError) forwardError.style.display = 'none';
});
}
window.addEventListener('click', function(e) {
if (e.target === forwardModal) {
closeModal(forwardModal);
if (forwardError) forwardError.style.display = 'none';
}
});
// Check if there's a server error and re-open modal
const serverError = forwardModal.querySelector('.unlock-error');
if (serverError && serverError.textContent.trim() && !serverError.id) {
openModal(forwardModal);
if (forwardEmail) forwardEmail.focus();
}
// Check for success message in URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('forwarded') === 'true') {
// Remove the query param from URL without reload
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}
function initForwardAllModal() {
const forwardAllModal = document.getElementById('forwardAllModal');
const forwardAllBtn = document.getElementById('forwardAllBtn');
const closeForwardAll = document.getElementById('closeForwardAll');
const forwardAllForm = document.querySelector('#forwardAllModal form');
const forwardAllEmail = document.getElementById('forwardAllEmail');
const forwardAllError = document.getElementById('forwardAllError');
if (!forwardAllModal || !forwardAllBtn) return;
const openModal = (m) => { if (m) m.style.display = 'block'; };
const closeModal = (m) => { if (m) m.style.display = 'none'; };
forwardAllBtn.onclick = function(e) {
e.preventDefault();
openModal(forwardAllModal);
if (forwardAllEmail) forwardAllEmail.focus();
};
if (closeForwardAll) {
closeForwardAll.onclick = function() {
closeModal(forwardAllModal);
if (forwardAllError) forwardAllError.style.display = 'none';
};
}
if (forwardAllForm) {
forwardAllForm.addEventListener('submit', function(e) {
const email = forwardAllEmail ? forwardAllEmail.value.trim() : '';
// Basic client-side validation
if (!email) {
e.preventDefault();
if (forwardAllError) {
forwardAllError.textContent = 'Please enter an email address.';
forwardAllError.style.display = 'block';
}
return;
}
// Simple email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
e.preventDefault();
if (forwardAllError) {
forwardAllError.textContent = 'Please enter a valid email address.';
forwardAllError.style.display = 'block';
}
return;
}
if (forwardAllError) forwardAllError.style.display = 'none';
});
}
window.addEventListener('click', function(e) {
if (e.target === forwardAllModal) {
closeModal(forwardAllModal);
if (forwardAllError) forwardAllError.style.display = 'none';
}
});
// Check for success message in URL
const urlParams = new URLSearchParams(window.location.search);
// Check if we just came from a forward all attempt with an error
// Look for error message in the page that might be related to forwarding
const pageErrorDiv = document.querySelector('.unlock-error');
if (pageErrorDiv && !urlParams.get('forwardedAll')) {
const errorText = pageErrorDiv.textContent.trim();
// Check if error is related to forwarding
if (errorText.includes('forward') || errorText.includes('email') || errorText.includes('25')) {
openModal(forwardAllModal);
if (forwardAllEmail) forwardAllEmail.focus();
// Move error into modal
if (forwardAllError) {
forwardAllError.textContent = errorText;
forwardAllError.style.display = 'block';
}
}
}
if (urlParams.get('forwardedAll')) {
// Remove the query param from URL without reload
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}
function initRefreshCountdown(refreshInterval) { function initRefreshCountdown(refreshInterval) {
const refreshTimer = document.getElementById('refreshTimer'); const refreshTimer = document.getElementById('refreshTimer');
if (!refreshTimer || !refreshInterval) return; if (!refreshTimer || !refreshInterval) return;
@ -377,7 +499,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Expose utilities and run them // Expose utilities and run them
window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle }; window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal };
formatEmailDates(); formatEmailDates();
formatMailDate(); formatMailDate();
initLockModals(); initLockModals();
@ -385,4 +507,6 @@ document.addEventListener('DOMContentLoaded', () => {
initQrModal(); initQrModal();
initHamburgerMenu(); initHamburgerMenu();
initThemeToggle(); initThemeToggle();
initForwardModal();
initCryptoKeysToggle();
}); });

View file

@ -297,6 +297,672 @@ 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 {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3rem;
align-items: start;
}
.auth-card {
background: var(--color-bg-dark);
border: 1px solid var(--color-border-dark);
border-radius: 8px;
padding: 2rem;
}
.auth-card h1 {
margin-bottom: 0.5rem;
color: var(--color-accent-purple);
}
.auth-subtitle {
color: var(--color-text-gray);
margin-bottom: 2rem;
}
.auth-card fieldset {
border: none;
padding: 0;
margin: 0;
}
.auth-card label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.auth-card small {
display: block;
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border-dark);
border-radius: 4px;
background: var(--color-bg-medium);
color: var(--color-text-light);
}
.auth-actions {
margin-top: 2rem;
}
.button-primary {
width: 100%;
background: var(--color-accent-purple);
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.button-primary:hover {
background: var(--color-accent-purple-light);
}
.auth-footer {
text-align: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border-dark);
}
.auth-footer a {
color: var(--color-accent-purple);
text-decoration: none;
font-weight: 600;
}
.auth-footer a:hover {
text-decoration: underline;
}
.auth-features {
padding: 2rem;
}
.auth-features h3,
.auth-features h4 {
color: var(--color-accent-purple);
margin-bottom: 1rem;
}
.auth-features ul {
list-style: none;
padding: 0;
}
.auth-features li {
padding: 0.75rem 0;
font-size: 1.05rem;
}
.auth-guest-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--color-border-dark);
}
@media (max-width: 768px) {
.auth-container {
grid-template-columns: 1fr;
gap: 2rem;
}
.auth-features {
padding: 1rem;
}
}
/* 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 {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 2rem;
}
.verification-success-card {
background: var(--color-bg-dark);
border: 2px solid #28a745;
border-radius: 8px;
padding: 3rem 2rem;
max-width: 500px;
width: 100%;
text-align: center;
box-shadow: 0 4px 6px var(--overlay-black-40);
}
.verification-success-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
color: #28a745;
}
.verification-success-icon svg {
width: 100%;
height: 100%;
}
.verification-success-card h1 {
color: #28a745;
margin-bottom: 1.5rem;
font-size: 1.8rem;
}
.verification-details {
margin: 2rem 0;
padding: 1rem;
background: var(--overlay-white-05);
border-radius: 5px;
}
.verification-details p {
margin: 0.5rem 0;
}
.verified-email {
font-family: monospace;
font-size: 1.1rem;
font-weight: bold;
color: var(--color-accent-purple);
word-break: break-all;
}
.verification-info {
margin: 2rem 0;
text-align: left;
padding: 0 1rem;
}
.verification-info p {
margin: 0.75rem 0;
line-height: 1.6;
}
.verification-actions {
margin-top: 2rem;
}
.verification-actions .button-primary {
display: inline-block;
width: auto;
padding: 0.75rem 2rem;
border-radius: 5px;
text-decoration: none;
}
@media (max-width: 600px) {
.verification-success-card {
padding: 2rem 1rem;
}
.verification-success-card h1 {
font-size: 1.5rem;
}
}
/* 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(2, 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);
}
form {
margin: 0;
}
.inline-form {
display: inline;
align-self: center;
}
.button-small {
padding: 0rem 1rem;
font-size: 0.85rem;
margin: 0;
}
.button-danger {
background: var(--color-danger);
color: white;
border: none;
}
.button-danger:hover {
background: #c0392b;
}
.empty-state {
color: var(--color-text-gray);
font-style: italic;
text-align: center;
padding: 2rem;
}
.hint {
color: var(--color-text-gray);
font-size: 0.9rem;
margin-top: 1rem;
text-align: center;
}
.limit-reached {
color: var(--color-warning);
font-weight: 600;
text-align: center;
margin-top: 1rem;
}
@media (max-width: 768px) {
.account-grid {
grid-template-columns: 1fr;
}
.email-item,
.inbox-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.inline-form {
width: 100%;
}
.button-small {
width: 100%;
}
}
/* Forward modal auth prompts */
.auth-required {
background: var(--overlay-warning-10);
border-left: 3px solid var(--color-warning);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.auth-prompt {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.auth-prompt .button {
flex: 1;
text-align: center;
}
.form-hint {
display: block;
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.form-hint a {
color: var(--color-accent-purple);
text-decoration: none;
}
.form-hint a:hover {
text-decoration: underline;
}
.modal-info {
color: var(--color-text-gray);
font-size: 0.9rem;
text-align: center;
margin: 0.5rem 0;
}
/* Reset apple form styles */ /* Reset apple form styles */
input, input,
@ -880,7 +1546,7 @@ label {
.attachment-link { .attachment-link {
color: var(--color-accent-purple-light); color: var(--color-accent-purple-light);
padding: 12px 16px; padding: 0px 16px;
background: var(--overlay-purple-10); background: var(--overlay-purple-10);
border: 1px solid var(--overlay-purple-20); border: 1px solid var(--overlay-purple-20);
transition: all 0.3s ease; transition: all 0.3s ease;
@ -931,6 +1597,16 @@ label {
font-size: 1.4rem; font-size: 1.4rem;
} }
.modal-info {
color: var(--color-text-primary);
margin-bottom: 1rem;
padding: 0.8rem;
background: var(--overlay-purple-08);
border-left: 3px solid var(--color-accent-purple);
border-radius: 4px;
font-size: 1.3rem;
}
.close { .close {
float: right; float: right;
font-size: 2.8rem; font-size: 2.8rem;
@ -993,6 +1669,37 @@ label {
} }
/* Success Messages */
.success-message {
color: #2ecc71;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(46, 204, 113, 0.1);
border-left: 3px solid #2ecc71;
border-radius: 4px;
font-weight: 500;
}
/* Verification Messages */
.verification-message {
color: #3498db;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(52, 152, 219, 0.1);
border-left: 3px solid #3498db;
border-radius: 4px;
font-weight: 500;
}
.verification-message strong {
color: #2980b9;
font-weight: 600;
}
/* Remove Lock Button Styles */ /* Remove Lock Button Styles */
.modal-button-danger { .modal-button-danger {
@ -1045,7 +1752,7 @@ label {
} }
.raw-tab-button { .raw-tab-button {
padding: 8px 14px; padding: 0px 14px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--overlay-white-12); border: 1px solid var(--overlay-white-12);
background: var(--overlay-white-05); background: var(--overlay-white-05);

View file

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

View file

@ -0,0 +1,249 @@
const express = require('express')
const router = new express.Router()
const { body, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:auth-routes')
const { redirectIfAuthenticated } = require('../middleware/auth')
// Simple in-memory rate limiters for registration and login
const registrationRateLimitStore = new Map()
const loginRateLimitStore = new Map()
// Registration rate limiter: 5 attempts per IP per hour
const registrationRateLimiter = (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress
const now = Date.now()
const windowMs = 60 * 60 * 1000 // 1 hour
const maxRequests = 5
// Clean up old entries
for (const [key, data] of registrationRateLimitStore.entries()) {
if (now - data.resetTime > windowMs) {
registrationRateLimitStore.delete(key)
}
}
// Get or create entry for this IP
let ipData = registrationRateLimitStore.get(ip)
if (!ipData || now - ipData.resetTime > windowMs) {
ipData = { count: 0, resetTime: now }
registrationRateLimitStore.set(ip, ipData)
}
// Check if limit exceeded
if (ipData.count >= maxRequests) {
debug(`Registration rate limit exceeded for IP ${ip}`)
req.session.errorMessage = 'Too many registration attempts. Please try again after 1 hour.'
return res.redirect('/register')
}
// Increment counter
ipData.count++
next()
}
// Login rate limiter: 10 attempts per IP per 15 minutes
const loginRateLimiter = (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxRequests = 10
// Clean up old entries
for (const [key, data] of loginRateLimitStore.entries()) {
if (now - data.resetTime > windowMs) {
loginRateLimitStore.delete(key)
}
}
// Get or create entry for this IP
let ipData = loginRateLimitStore.get(ip)
if (!ipData || now - ipData.resetTime > windowMs) {
ipData = { count: 0, resetTime: now }
loginRateLimitStore.set(ip, ipData)
}
// Check if limit exceeded
if (ipData.count >= maxRequests) {
debug(`Login rate limit exceeded for IP ${ip}`)
req.session.errorMessage = 'Too many login attempts. Please try again after 15 minutes.'
return res.redirect('/login')
}
// Increment counter
ipData.count++
next()
}
// 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
// Clear messages after reading
delete req.session.errorMessage
delete req.session.successMessage
res.render('auth', {
title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding,
errorMessage,
successMessage
})
})
// POST /register - Process registration
router.post('/register',
redirectIfAuthenticated,
registrationRateLimiter,
body('username').trim().notEmpty().withMessage('Username is required'),
body('password').notEmpty().withMessage('Password is required'),
body('confirmPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
const firstError = errors.array()[0].msg
debug(`Registration validation failed: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect('/auth')
}
const { username, password, confirmPassword } = req.body
// Check if passwords match
if (password !== confirmPassword) {
debug('Registration failed: Passwords do not match')
req.session.errorMessage = 'Passwords do not match'
return res.redirect('/auth')
}
const authService = req.app.get('authService')
const result = await authService.register(username, password)
if (result.success) {
debug(`User registered successfully: ${username}`)
req.session.successMessage = 'Registration successful! Please log in.'
return res.redirect('/auth')
} else {
debug(`Registration failed: ${result.error}`)
req.session.errorMessage = result.error
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('/auth')
}
}
)
// POST /login - Process login
router.post('/login',
redirectIfAuthenticated,
loginRateLimiter,
body('username').trim().notEmpty().withMessage('Username is required'),
body('password').notEmpty().withMessage('Password is required'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
const firstError = errors.array()[0].msg
debug(`Login validation failed: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect('/auth')
}
const { username, password } = req.body
const authService = req.app.get('authService')
const result = await authService.login(username, password)
if (result.success) {
debug(`User logged in successfully: ${username}`)
// Store redirect URL before regenerating session
const redirectUrl = req.session.redirectAfterLogin || '/'
// Regenerate session to prevent fixation attacks
req.session.regenerate((err) => {
if (err) {
debug(`Session regeneration error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/auth')
}
// Set session data
req.session.userId = result.user.id
req.session.username = result.user.username
req.session.isAuthenticated = true
req.session.createdAt = result.user.created_at
req.session.save((err) => {
if (err) {
debug(`Session save error: ${err.message}`)
req.session.errorMessage = 'Login failed. Please try again.'
return res.redirect('/auth')
}
debug(`Session created for user: ${username}, redirecting to: ${redirectUrl}`)
res.redirect(redirectUrl)
})
})
} else {
debug(`Login failed: ${result.error}`)
req.session.errorMessage = result.error
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('/auth')
}
}
)
// GET /logout - Logout user
router.get('/logout', (req, res) => {
// Store redirect URL before destroying session
const redirectUrl = req.query.redirect || req.get('Referer') || '/'
debug(`Logout requested with redirect: ${redirectUrl}`)
if (req.session) {
const username = req.session.username
req.session.destroy((err) => {
if (err) {
debug(`Logout error: ${err.message}`)
console.error('Error during logout', err)
return res.redirect('/')
}
debug(`User logged out: ${username}, redirecting to: ${redirectUrl}`)
// Clear cookie explicitly
res.clearCookie('connect.sid')
res.redirect(redirectUrl)
})
} else {
debug(`No session found, redirecting to: ${redirectUrl}`)
res.redirect(redirectUrl)
}
}) // GET /auth/check - JSON endpoint for checking auth status (AJAX)
router.get('/auth/check', (req, res) => {
if (req.session && req.session.userId && req.session.isAuthenticated) {
res.json({
authenticated: true,
user: {
id: req.session.userId,
username: req.session.username
}
})
} else {
res.json({
authenticated: false
})
}
})
module.exports = router

View file

@ -1,6 +1,6 @@
const express = require('express') const express = require('express')
const router = new express.Router() const router = new express.Router()
const { param } = require('express-validator') const { param, body, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:routes') const debug = require('debug')('48hr-email:routes')
const config = require('../../../application/config') const config = require('../../../application/config')
@ -9,6 +9,7 @@ const CryptoDetector = require('../../../application/crypto-detector')
const helper = new(Helper) const helper = new(Helper)
const cryptoDetector = new CryptoDetector() const cryptoDetector = new CryptoDetector()
const { checkLockAccess } = require('../middleware/lock') const { checkLockAccess } = require('../middleware/lock')
const { requireAuth, optionalAuth } = require('../middleware/auth')
const purgeTime = helper.purgeTimeElemetBuilder() const purgeTime = helper.purgeTimeElemetBuilder()
@ -40,7 +41,64 @@ const validateDomain = (req, res, next) => {
next() next()
} }
router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLockAccess, async(req, res, next) => { // Simple in-memory rate limiter for forwarding (5 requests per 15 minutes per IP)
const forwardRateLimitStore = new Map()
const forwardLimiter = (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxRequests = 5
// Clean up old entries
for (const [key, data] of forwardRateLimitStore.entries()) {
if (now - data.resetTime > windowMs) {
forwardRateLimitStore.delete(key)
}
}
// Get or create entry for this IP
let ipData = forwardRateLimitStore.get(ip)
if (!ipData || now - ipData.resetTime > windowMs) {
ipData = { count: 0, resetTime: now }
forwardRateLimitStore.set(ip, ipData)
}
// Check if limit exceeded
if (ipData.count >= maxRequests) {
debug(`Rate limit exceeded for IP ${ip}`)
req.session.errorMessage = 'Too many forward requests. Please try again after 15 minutes.'
return res.redirect(`/inbox/${req.params.address}`)
}
// Increment counter
ipData.count++
next()
}
// Email validation middleware for forwarding
const validateForwardRequest = [
sanitizeAddress,
body('destinationEmail')
.trim()
.isEmail()
.withMessage('Invalid email address format')
.normalizeEmail()
.custom((value) => {
// Prevent forwarding to temporary email addresses
const domain = value.split('@')[1]
if (!domain) {
throw new Error('Invalid email address')
}
const tempDomains = config.email.domains.map(d => d.toLowerCase())
if (tempDomains.includes(domain.toLowerCase())) {
throw new Error('Cannot forward to temporary email addresses')
}
return true
})
]
router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optionalAuth, checkLockAccess, async(req, res, next) => {
try { try {
const mailProcessingService = req.app.get('mailProcessingService') const mailProcessingService = req.app.get('mailProcessingService')
if (!mailProcessingService) { if (!mailProcessingService) {
@ -52,17 +110,43 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
const largestUid = await req.app.locals.imapService.getLargestUid() const largestUid = await req.app.locals.imapService.getLargestUid()
const totalcount = helper.countElementBuilder(count, largestUid) const totalcount = helper.countElementBuilder(count, largestUid)
debug(`Rendering inbox with ${count} total mails`) debug(`Rendering inbox with ${count} total mails`)
// Check lock status
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address const userId = req.session && req.session.userId
const isAuthenticated = req.session && 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 && req.session.lockedInbox === req.params.address)
// Get user's verified emails if logged in
let userForwardEmails = []
if (req.session && req.session.userId) {
const userRepository = req.app.get('userRepository')
if (userRepository) {
userForwardEmails = userRepository.getForwardEmails(req.session.userId)
}
}
// Pull any lock error from session and clear it after reading // Pull any lock error from session and clear it after reading
const lockError = req.session ? req.session.lockError : undefined const lockError = req.session ? req.session.lockError : undefined
const unlockErrorSession = req.session ? req.session.unlockError : undefined const unlockErrorSession = req.session ? req.session.unlockError : undefined
const errorMessage = req.session ? req.session.errorMessage : undefined
if (req.session) { if (req.session) {
delete req.session.lockError delete req.session.lockError
delete req.session.unlockError delete req.session.unlockError
delete req.session.errorMessage
} }
// Check for forward all success flag
const forwardAllSuccess = req.query.forwardedAll ? parseInt(req.query.forwardedAll) : null
// Check for verification sent flag
const verificationSent = req.query.verificationSent === 'true'
const verificationEmail = req.query.email || ''
res.render('inbox', { res.render('inbox', {
title: `${config.http.branding[0]} | ` + req.params.address, title: `${config.http.branding[0]} | ` + req.params.address,
purgeTime: purgeTime, purgeTime: purgeTime,
@ -71,16 +155,22 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
totalcount: totalcount, totalcount: totalcount,
mailSummaries: mailProcessingService.getMailSummaries(req.params.address), mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
branding: config.http.branding, branding: config.http.branding,
lockEnabled: config.lock.enabled, authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked, isLocked: isLocked,
hasAccess: hasAccess, hasAccess: hasAccess,
unlockError: unlockErrorSession, unlockError: unlockErrorSession,
locktimer: config.lock.releaseHours, locktimer: config.user.lockReleaseHours,
error: lockError, error: lockError,
redirectTo: req.originalUrl, redirectTo: req.originalUrl,
expiryTime: config.email.purgeTime.time, expiryTime: config.email.purgeTime.time,
expiryUnit: config.email.purgeTime.unit, expiryUnit: config.email.purgeTime.unit,
refreshInterval: config.imap.refreshIntervalSeconds refreshInterval: config.imap.refreshIntervalSeconds,
errorMessage: errorMessage,
forwardAllSuccess: forwardAllSuccess,
verificationSent: verificationSent,
verificationEmail: verificationEmail
}) })
} catch (error) { } catch (error) {
debug(`Error loading inbox for ${req.params.address}:`, error.message) debug(`Error loading inbox for ${req.params.address}:`, error.message)
@ -93,6 +183,7 @@ router.get(
'^/:address/:uid([0-9]+)', '^/:address/:uid([0-9]+)',
sanitizeAddress, sanitizeAddress,
validateDomain, validateDomain,
optionalAuth,
checkLockAccess, checkLockAccess,
async(req, res, next) => { async(req, res, next) => {
try { try {
@ -120,7 +211,35 @@ router.get(
const inboxLock = req.app.get('inboxLock') const inboxLock = req.app.get('inboxLock')
const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
const hasAccess = req.session && req.session.lockedInbox === req.params.address const userId = req.session && req.session.userId
const isAuthenticated = req.session && 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 && 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
if (req.session) {
delete req.session.errorMessage
}
// Check for forward success flag
const forwardSuccess = req.query.forwarded === 'true'
// Check for verification sent flag
const verificationSent = req.query.verificationSent === 'true'
const verificationEmail = req.query.email || ''
debug(`Rendering email view for UID ${req.params.uid}`) debug(`Rendering email view for UID ${req.params.uid}`)
res.render('mail', { res.render('mail', {
@ -133,9 +252,15 @@ router.get(
cryptoAttachments: cryptoAttachments, cryptoAttachments: cryptoAttachments,
uid: req.params.uid, uid: req.params.uid,
branding: config.http.branding, branding: config.http.branding,
lockEnabled: config.lock.enabled, authEnabled: config.user.authEnabled,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked, isLocked: isLocked,
hasAccess: hasAccess hasAccess: hasAccess,
errorMessage: errorMessage,
forwardSuccess: forwardSuccess,
verificationSent: verificationSent,
verificationEmail: verificationEmail
}) })
} else { } else {
debug(`Email ${req.params.uid} not found for ${req.params.address}`) debug(`Email ${req.params.uid} not found for ${req.params.address}`)
@ -331,6 +456,157 @@ router.get(
} }
) )
// POST route for forwarding a single email (requires authentication)
router.post(
'^/:address/:uid/forward',
requireAuth,
forwardLimiter,
validateDomain,
checkLockAccess,
validateForwardRequest,
async(req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
const firstError = errors.array()[0].msg
debug(`Forward validation failed for ${req.params.address}: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect(`/inbox/${req.params.address}/${req.params.uid}`)
}
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 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}`)
}
// Email is verified, proceed with forwarding
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (user verified)`)
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 {
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}`)
console.error('Error while forwarding email', error)
req.session.errorMessage = 'An unexpected error occurred while forwarding the email.'
res.redirect(`/inbox/${req.params.address}/${req.params.uid}`)
}
}
)
// POST route for forwarding all emails in an inbox (requires authentication)
router.post(
'^/:address/forward-all',
requireAuth,
forwardLimiter,
validateDomain,
checkLockAccess,
validateForwardRequest,
async(req, res, next) => {
try {
const validationErrors = validationResult(req)
if (!validationErrors.isEmpty()) {
const firstError = validationErrors.array()[0].msg
debug(`Forward all validation failed for ${req.params.address}: ${firstError}`)
req.session.errorMessage = firstError
return res.redirect(`/inbox/${req.params.address}`)
}
const mailProcessingService = req.app.get('mailProcessingService')
const userRepository = req.app.get('userRepository')
const { destinationEmail } = req.body
// 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}`)
}
// Email is verified, proceed with bulk forwarding
debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail} (user verified)`)
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
// Limit bulk forwarding to 25 emails
const MAX_FORWARD_ALL = 25
if (mailSummaries.length > MAX_FORWARD_ALL) {
debug(`Forward all blocked: ${mailSummaries.length} emails exceeds limit of ${MAX_FORWARD_ALL}`)
req.session.errorMessage = `Cannot forward more than ${MAX_FORWARD_ALL} emails at once. You have ${mailSummaries.length} emails.`
return res.redirect(`/inbox/${req.params.address}`)
}
if (mailSummaries.length === 0) {
debug(`No emails to forward for ${req.params.address}`)
req.session.errorMessage = 'No emails to forward.'
return res.redirect(`/inbox/${req.params.address}`)
}
let successCount = 0
let failCount = 0
const failMessages = []
for (const mail of mailSummaries) {
const result = await mailProcessingService.forwardEmail(
req.params.address,
mail.uid,
destinationEmail
)
if (result.success) {
successCount++
debug(`Successfully forwarded email UID ${mail.uid}`)
} else {
failCount++
debug(`Failed to forward email UID ${mail.uid}: ${result.error}`)
failMessages.push(`UID ${mail.uid}: ${result.error}`)
}
}
debug(`Forward all complete: ${successCount} succeeded, ${failCount} failed`)
if (successCount > 0 && failCount === 0) {
return res.redirect(`/inbox/${req.params.address}?forwardedAll=${successCount}`)
} else if (successCount > 0 && failCount > 0) {
req.session.errorMessage = `Forwarded ${successCount} email(s), but ${failCount} failed.`
return res.redirect(`/inbox/${req.params.address}`)
} else {
req.session.errorMessage = `Failed to forward emails: ${failMessages[0] || 'Unknown error'}`
return res.redirect(`/inbox/${req.params.address}`)
}
} catch (error) {
debug(`Error forwarding all emails: ${error.message}`)
console.error('Error while forwarding all emails', error)
req.session.errorMessage = 'An unexpected error occurred while forwarding emails.'
res.redirect(`/inbox/${req.params.address}`)
}
}
)
// Final catch-all for invalid UIDs (non-numeric or unmatched patterns) // Final catch-all for invalid UIDs (non-numeric or unmatched patterns)
router.get( router.get(
'^/:address/:uid', '^/:address/:uid',
@ -342,5 +618,54 @@ router.get(
} }
) )
// GET route for email verification (token verification)
router.get('/verify', async(req, res, next) => {
try {
const { token } = req.query
if (!token) {
debug('Verification attempt without token')
req.session.errorMessage = 'Verification token is required'
return res.redirect('/')
}
const verificationStore = req.app.get('verificationStore')
if (!verificationStore) {
debug('Verification store not available')
req.session.errorMessage = 'Email verification is not configured'
return res.redirect('/')
}
// Verify the token
const verification = verificationStore.verifyToken(token)
if (!verification) {
debug(`Invalid or expired verification token: ${token}`)
req.session.errorMessage = 'This verification link is invalid or has expired. Please request a new verification email.'
return res.redirect('/')
}
// Token is valid, set signed cookie
const destinationEmail = verification.destinationEmail
const cookieMaxAge = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
res.cookie('verified_email', destinationEmail, {
maxAge: cookieMaxAge,
httpOnly: true,
signed: true,
sameSite: 'lax'
})
debug(`Email ${destinationEmail} verified successfully, cookie set for 24 hours`)
// Show success on account page
req.session.accountSuccess = `Successfully verified ${destinationEmail}!`
return res.redirect('/account')
} catch (error) {
debug(`Error during verification: ${error.message}`)
console.error('Error during email verification', error)
req.session.errorMessage = 'An error occurred during verification'
res.redirect('/')
}
})
module.exports = router module.exports = router

View file

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

View file

@ -45,14 +45,7 @@ router.get('/inbox/random', (req, res, _next) => {
res.redirect(`/inbox/${inbox}`) res.redirect(`/inbox/${inbox}`)
}) })
router.get('/logout', (req, res, _next) => { // Legacy logout route removed - handled by auth.js
/**
* If we ever need a logout sequence, now we can have one!
*/
res.redirect('/')
})
router.post( router.post(
'/', [ '/', [

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. Locks auto-release after 7 days without login.</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

@ -0,0 +1,124 @@
{% 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="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>
{% endif %}
{% if successMessage %}
<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="login-username">Username</label>
<input
type="text"
id="login-username"
name="username"
placeholder="Your username"
required
autocomplete="username"
>
<label for="login-password">Password</label>
<input
type="password"
id="login-password"
name="password"
placeholder="Your password"
required
autocomplete="current-password"
>
<button class="button button-primary" type="submit">Login</button>
</fieldset>
</form>
</div>
<!-- Register Form -->
<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-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 to your account</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

@ -2,10 +2,24 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
{% if showUnlockButton %} {% if currentUser %}
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a> <!-- 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?redirect=/" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %} {% endif %}
<a href="/" aria-label="Return to home">Logout</a> {% else %}
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %}
{% endif %}
<a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
@ -21,48 +35,4 @@
<h1>{{message}}</h1> <h1>{{message}}</h1>
<h2>{{error.status}}</h2> <h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre> <pre>{{error.stack}}</pre>
{% if showUnlockButton %}
<div id="unlockModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeUnlock">&times;</span>
<h3>Unlock Inbox</h3>
<p class="modal-description">Enter password to access this locked inbox.</p>
{% if unlockError %}
<p class="unlock-error">
{% if unlockError == 'invalid_password' %}
Invalid password. Please try again.
{% elseif unlockError == 'missing_fields' %}
Please provide a password.
{% else %}
An error occurred. Please try again.
{% endif %}
</p>
{% endif %}
<form method="POST" action="/lock/unlock">
<input type="hidden" name="address" value="{{ address }}">
<input type="hidden" name="redirectTo" value="{{ redirectTo|default(address) }}">
<fieldset>
<label for="unlockPassword" class="floating-label">Password</label>
<input type="password" id="unlockPassword" name="password" required class="modal-input">
<button type="submit" class="button-primary modal-button">Unlock</button>
</fieldset>
</form>
</div>
</div>
<script>
const modal = document.getElementById('unlockModal');
const btn = document.getElementById('unlockBtn');
const close = document.getElementById('closeUnlock');
if (btn) btn.onclick = (e) => { e.preventDefault(); modal.style.display = 'block'; };
if (close) close.onclick = () => modal.style.display = 'none';
window.onclick = (e) => { if (e.target == modal) modal.style.display = 'none'; };
// Auto-open modal if there's an unlock error
if ('{{ unlockError|default("") }}') {
modal.style.display = 'block';
}
</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -2,21 +2,42 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
{% if lockEnabled %} {% 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 %} {% if isLocked and hasAccess %}
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a> <a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a>
{% elseif isLocked %} {% elseif not isLocked %}
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a> <a href="#" id="lockBtn" aria-label="Lock inbox to your account">Lock Inbox</a>
{% else %}
<a href="#" id="lockBtn" aria-label="Protect inbox with password">Protect Inbox</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a> <a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
{% if lockEnabled and hasAccess %} </div>
<a href="/lock/logout" aria-label="Logout">Logout</a> </div>
{% else %}
<a href="/logout" aria-label="Logout">Logout</a> <!-- 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?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %} {% 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 %}
<a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
@ -31,6 +52,21 @@
{% block body %} {% block body %}
<script src="/javascripts/qrcode.js"></script> <script src="/javascripts/qrcode.js"></script>
<script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script> <script src="/javascripts/inbox-init.js" defer data-address="{{ address }}" data-expiry-time="{{ expiryTime }}" data-expiry-unit="{{ expiryUnit }}" data-refresh-interval="{{ refreshInterval }}"></script>
{% if forwardAllSuccess %}
<div class="success-message">
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
</div>
{% endif %}
{% if verificationSent %}
<div class="verification-message">
Verification email sent to <strong>{{ verificationEmail }}</strong>. Please check your inbox and click the verification link (expires in 15 minutes).
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
{% endif %}
<div class="inbox-container"> <div class="inbox-container">
<div class="inbox-header"> <div class="inbox-header">
<h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address }}</h1> <h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address }}</h1>
@ -72,26 +108,27 @@
</blockquote> </blockquote>
{% endif %} {% endif %}
<div class="refresh-countdown" id="refreshCountdown" title="New emails are fetched from the server only when the timer hits zero. Reloading this page has no effect on fetching.">Fetching new mails in <span id="refreshTimer">--</span>s</div> <div class="refresh-countdown" id="refreshCountdown" title="New emails are fetched from the server only when the timer hits zero. Reloading this page has no effect on fetching.">Fetching new mails in <span id="refreshTimer">--</span>s</div>
{% if lockEnabled and not isLocked %} {% if authEnabled and not isLocked %}
<!-- Lock Modal --> <!-- Lock Modal -->
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}"> <div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
<div class="modal-content"> <div class="modal-content">
<span class="close" id="closeLock">&times;</span> <span class="close" id="closeLock">&times;</span>
<h3>Protect Inbox</h3> <h3>Lock Inbox</h3>
<p class="modal-description">Password-protect this inbox. Locked emails won't be deleted. Protection active for {{ locktimer }}hrs after last login.</p> <p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
{% if error and error == 'locking_disabled_for_example' %} {% if error and error == 'locking_disabled_for_example' %}
<p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p> <p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p>
{% elseif error and error == 'max_locked_inboxes' %}
<p id="lockServerError" class="unlock-error">You have reached the maximum of 5 locked inboxes. Please remove a lock before adding a new one.</p>
{% elseif error and error == 'already_locked' %}
<p id="lockServerError" class="unlock-error">This inbox is already locked by another user.</p>
{% elseif error and error == 'not_your_lock' %}
<p id="lockServerError" class="unlock-error">You don't own the lock on this inbox.</p>
{% endif %} {% endif %}
<p id="lockErrorInline" class="unlock-error" style="display:none"></p> <p id="lockErrorInline" class="unlock-error" style="display:none"></p>
<form method="POST" action="/lock/lock"> <form method="POST" action="/lock/lock">
<input type="hidden" name="address" value="{{ address }}"> <input type="hidden" name="address" value="{{ address }}">
<fieldset> <fieldset>
<label for="lockPassword" class="floating-label">Password (min 8 characters)</label> <p>This inbox will be protected with your account. Only you will be able to access it while logged in.</p>
<input type="password" id="lockPassword" name="password" placeholder="Password" required minlength="8" class="modal-input">
<label for="lockConfirm" class="floating-label">Confirm Password</label>
<input type="password" id="lockConfirm" placeholder="Confirm" required minlength="8" class="modal-input">
<button type="submit" class="button-primary modal-button">Lock Inbox</button> <button type="submit" class="button-primary modal-button">Lock Inbox</button>
</fieldset> </fieldset>
</form> </form>
@ -100,45 +137,15 @@
{% endif %} {% endif %}
{% if lockEnabled 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 lockEnabled and isLocked and hasAccess %} {% if authEnabled and isLocked and hasAccess %}
<!-- Remove Lock Modal --> <!-- Remove Lock Modal -->
<div id="removeLockModal" class="modal" style="display: none;"> <div id="removeLockModal" class="modal" style="display: none;">
<div class="modal-content"> <div class="modal-content">
<span class="close" id="closeRemoveLock">&times;</span> <span class="close" id="closeRemoveLock">&times;</span>
<h3>Remove Password Lock</h3> <h3>Remove Lock</h3>
<p class="modal-description">Are you sure you want to remove the password lock from this inbox? This cannot be undone.</p> <p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>
<form method="POST" action="/lock/remove"> <form method="POST" action="/lock/remove">
<input type="hidden" name="address" value="{{ address }}"> <input type="hidden" name="address" value="{{ address }}">
<fieldset> <fieldset>
@ -162,4 +169,43 @@
</div> </div>
</div> </div>
<!-- Forward All Modal -->
<div id="forwardAllModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span>
<h3>Forward All Emails</h3>
{% 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">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 %} {% endblock %}

View file

@ -2,7 +2,24 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
<a href="/inbox/{{ example }}">Example Inbox</a> {% if currentUser %}
<!-- Account Dropdown (logged in) -->
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %}
{% else %}
{% if authEnabled %}
<a href="/auth" aria-label="Login or Register">Account</a>
{% endif %}
{% endif %}
<a href="/inbox/{{ example }}" aria-label="View example inbox">Example Inbox</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>

View file

@ -3,13 +3,38 @@
{% block header %} {% block header %}
<div class="action-links"> <div class="action-links">
<a href="/inbox/{{ address }}" aria-label="Return to inbox">← Return to inbox</a> <a href="/inbox/{{ address }}" aria-label="Return to inbox">← Return to inbox</a>
<a href="/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> <a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
{% if lockEnabled and isLocked and hasAccess %} </div>
<a href="/lock/logout" aria-label="Logout">Logout</a> </div>
{% else %}
<a href="/logout" aria-label="Logout">Logout</a> <!-- 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?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
</div>
</div>
{% endif %} {% 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 %}
<a href="/" aria-label="Return to home">Home</a>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode"> <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
@ -22,6 +47,16 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% if forwardSuccess %}
<div class="success-message">
✓ Email forwarded successfully!
</div>
{% endif %}
{% if verificationSent %}
<div class="verification-message">
Verification email sent to <strong>{{ verificationEmail }}</strong>. Please check your inbox and click the verification link (expires in 15 minutes).
</div>
{% endif %}
<div class="mail-container"> <div class="mail-container">
<div class="mail-header"> <div class="mail-header">
<h1 class="mail-subject">{{ mail.subject }}</h1> <h1 class="mail-subject">{{ mail.subject }}</h1>
@ -88,16 +123,48 @@
{% endif %} {% endif %}
</div> </div>
<!-- Forward Email Modal -->
<div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3>
{% 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">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>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{{ parent() }} {{ parent() }}
<script>
// Initialize crypto keys toggle
document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initCryptoKeysToggle === 'function') {
window.utils.initCryptoKeysToggle();
}
});
</script>
{% endblock %} {% endblock %}

View file

@ -3,6 +3,7 @@ const http = require('http')
const debug = require('debug')('48hr-email:server') const debug = require('debug')('48hr-email:server')
const express = require('express') const express = require('express')
const session = require('express-session') const session = require('express-session')
const cookieParser = require('cookie-parser')
const logger = require('morgan') const logger = require('morgan')
const Twig = require('twig') const Twig = require('twig')
const compression = require('compression') const compression = require('compression')
@ -14,6 +15,8 @@ const inboxRouter = require('./routes/inbox')
const loginRouter = require('./routes/login') const loginRouter = require('./routes/login')
const errorRouter = require('./routes/error') const errorRouter = require('./routes/error')
const lockRouter = require('./routes/lock') const lockRouter = require('./routes/lock')
const authRouter = require('./routes/auth')
const accountRouter = require('./routes/account')
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters') const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
const Helper = require('../../application/helper') const Helper = require('../../application/helper')
@ -40,21 +43,23 @@ app.use(logger('dev'))
app.use(express.json()) app.use(express.json())
app.use(express.urlencoded({ extended: false })) app.use(express.urlencoded({ extended: false }))
// Session support for inbox locking // Cookie parser for signed cookies (email verification)
if (config.lock.enabled) { app.use(cookieParser(config.user.sessionSecret))
const session = require('express-session')
// Session support (always enabled for forward verification and inbox locking)
app.use(session({ app.use(session({
secret: config.lock.sessionSecret, secret: config.user.sessionSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
})) }))
}
// Clear session when user goes Home so locked inboxes require password again // Clear lock session data when user goes Home (but preserve authentication)
app.get('/', (req, res, next) => { app.get('/', (req, res, next) => {
if (config.lock.enabled && req.session) { if (req.session && req.session.lockedInbox) {
req.session.destroy(() => next()) // Only clear lock-related data, preserve user authentication
delete req.session.lockedInbox
req.session.save(() => next())
} else { } else {
next() next()
} }
@ -85,6 +90,19 @@ app.use(
) )
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter) Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
// Middleware to expose user session to all templates
app.use((req, res, next) => {
res.locals.authEnabled = config.user.authEnabled
res.locals.currentUser = null
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
res.locals.currentUser = {
id: req.session.userId,
username: req.session.username
}
}
next()
})
// Middleware to show loading page until IMAP is ready // Middleware to show loading page until IMAP is ready
app.use((req, res, next) => { app.use((req, res, next) => {
const isImapReady = req.app.get('isImapReady') const isImapReady = req.app.get('isImapReady')
@ -95,11 +113,13 @@ app.use((req, res, next) => {
}) })
app.use('/', loginRouter) app.use('/', loginRouter)
if (config.user.authEnabled) {
app.use('/', authRouter)
app.use('/', accountRouter)
}
app.use('/inbox', inboxRouter) app.use('/inbox', inboxRouter)
app.use('/error', errorRouter) app.use('/error', errorRouter)
if (config.lock.enabled) {
app.use('/lock', lockRouter) app.use('/lock', lockRouter)
}
// Catch 404 and forward to error handler // Catch 404 and forward to error handler
app.use((req, res, next) => { app.use((req, res, next) => {

24
package-lock.json generated
View file

@ -1,18 +1,19 @@
{ {
"name": "48hr.email", "name": "48hr.email",
"version": "1.7.5", "version": "1.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "48hr.email", "name": "48hr.email",
"version": "1.7.5", "version": "1.8.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"async-retry": "^1.3.3", "async-retry": "^1.3.3",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"debug": "^4.4.3", "debug": "^4.4.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.22.1", "express": "^4.22.1",
@ -1837,6 +1838,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "48hr.email", "name": "48hr.email",
"version": "1.8.1", "version": "1.9.0",
"private": false, "private": false,
"description": "48hr.email is your favorite open-source tempmail client.", "description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [ "keywords": [
@ -34,6 +34,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"debug": "^4.4.3", "debug": "^4.4.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.22.1", "express": "^4.22.1",

53
schema.sql Normal file
View file

@ -0,0 +1,53 @@
-- User Registration System Schema
-- SQLite database for user accounts and associated features
-- Users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_login INTEGER,
CHECK (length(username) >= 3 AND length(username) <= 20)
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
-- User verified forwarding emails
CREATE TABLE IF NOT EXISTS user_forward_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email TEXT NOT NULL COLLATE NOCASE,
verified_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, email)
);
CREATE INDEX IF NOT EXISTS idx_forward_emails_user_id ON user_forward_emails(user_id);
CREATE INDEX IF NOT EXISTS idx_forward_emails_email ON user_forward_emails(email);
-- User locked inboxes
CREATE TABLE IF NOT EXISTS user_locked_inboxes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
inbox_address TEXT NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
locked_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, inbox_address)
);
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_user_id ON user_locked_inboxes(user_id);
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_address ON user_locked_inboxes(inbox_address);
CREATE INDEX IF NOT EXISTS idx_locked_inboxes_last_accessed ON user_locked_inboxes(last_accessed);
-- Trigger to enforce max 5 locked inboxes per user
CREATE TRIGGER IF NOT EXISTS check_locked_inbox_limit
BEFORE INSERT ON user_locked_inboxes
BEGIN
SELECT RAISE(ABORT, 'User already has maximum number of locked inboxes')
WHERE (SELECT COUNT(*) FROM user_locked_inboxes WHERE user_id = NEW.user_id) >= 5;
END;