Compare commits
8 commits
cdce7e1e46
...
69011624a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69011624a7 | ||
|
|
48146fd385 | ||
|
|
ce03710cae | ||
|
|
8ed7ccade8 | ||
|
|
004d764238 | ||
|
|
598cea9b9c | ||
|
|
2a08aa14a8 | ||
|
|
8daa0fefe9 |
22
.env.example
|
|
@ -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
|
After Width: | Height: | Size: 209 KiB |
BIN
.github/assets/home.png
vendored
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
.github/assets/inbox.png
vendored
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 241 KiB |
BIN
.github/assets/keys.png
vendored
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 281 KiB |
BIN
.github/assets/raw.png
vendored
|
Before Width: | Height: | Size: 265 KiB |
|
|
@ -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"]
|
||||||
|
|
|
||||||
24
README.md
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
|
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
this._initTable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_initTable() {
|
/**
|
||||||
this.db.exec(`
|
* Lock an inbox for a user (no separate password needed - uses account ownership)
|
||||||
CREATE TABLE IF NOT EXISTS locked_inboxes (
|
* @param {number} userId - User ID
|
||||||
address TEXT PRIMARY KEY,
|
* @param {string} address - Inbox address to lock
|
||||||
password_hash TEXT NOT NULL,
|
* @returns {Promise<boolean>} - Success status
|
||||||
locked_at INTEGER NOT NULL,
|
*/
|
||||||
last_access INTEGER NOT NULL
|
async lock(userId, address) {
|
||||||
)
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async lock(address, password) {
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10)
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
|
||||||
INSERT INTO locked_inboxes (address, password_hash, locked_at, last_access)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stmt.run(address.toLowerCase(), passwordHash, now, now)
|
// Check if user can lock more inboxes (5 max)
|
||||||
return true
|
if (!this.canLockMore(userId)) {
|
||||||
} catch (error) {
|
throw new Error('You have reached the maximum of 5 locked inboxes')
|
||||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
}
|
||||||
|
|
||||||
|
// Check if inbox is already locked
|
||||||
|
if (this.isLocked(address)) {
|
||||||
throw new Error('This inbox is already locked')
|
throw new Error('This inbox is already locked')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO user_locked_inboxes (user_id, inbox_address, password_hash, locked_at, last_accessed)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Use empty password hash since we rely on user authentication
|
||||||
|
stmt.run(userId, address.toLowerCase(), '', now, now)
|
||||||
|
debug(`Inbox ${address} locked by user ${userId}`)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Failed to lock inbox ${address}:`, error.message)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlock(address, password) {
|
/**
|
||||||
const stmt = this.db.prepare('SELECT * FROM locked_inboxes WHERE address = ?')
|
* Unlock an inbox (verify user owns the lock)
|
||||||
const inbox = stmt.get(address.toLowerCase())
|
* @param {number} userId - User ID attempting to unlock
|
||||||
|
* @param {string} address - Inbox address to unlock
|
||||||
|
* @returns {Promise<Object|null>} - Lock info if successful, null if failed
|
||||||
|
*/
|
||||||
|
async unlock(userId, address) {
|
||||||
|
try {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM user_locked_inboxes
|
||||||
|
WHERE user_id = ? AND inbox_address = ?
|
||||||
|
`)
|
||||||
|
const lock = stmt.get(userId, address.toLowerCase())
|
||||||
|
|
||||||
if (!inbox) {
|
if (!lock) {
|
||||||
|
debug(`No lock found for user ${userId} on inbox ${address}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last access
|
||||||
|
this.updateAccess(userId, address)
|
||||||
|
debug(`Inbox ${address} unlocked by user ${userId}`)
|
||||||
|
return lock
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Error unlocking inbox ${address}:`, error.message)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(password, inbox.password_hash)
|
|
||||||
if (!valid) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last access
|
|
||||||
this.updateAccess(address)
|
|
||||||
return inbox
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an inbox is locked by any user
|
||||||
|
* @param {string} address - Inbox address
|
||||||
|
* @returns {boolean} - True if locked
|
||||||
|
*/
|
||||||
isLocked(address) {
|
isLocked(address) {
|
||||||
const stmt = this.db.prepare('SELECT address FROM locked_inboxes WHERE address = ?')
|
const stmt = this.db.prepare(`
|
||||||
return stmt.get(address.toLowerCase()) !== undefined
|
SELECT inbox_address FROM user_locked_inboxes
|
||||||
|
WHERE inbox_address = ?
|
||||||
|
`)
|
||||||
|
const result = stmt.get(address.toLowerCase())
|
||||||
|
return result !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAccess(address) {
|
/**
|
||||||
const stmt = this.db.prepare('UPDATE locked_inboxes SET last_access = ? WHERE address = ?')
|
* Check if an inbox is locked by a specific user
|
||||||
stmt.run(Date.now(), address.toLowerCase())
|
* @param {string} address - Inbox address
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @returns {boolean} - True if locked by this user
|
||||||
|
*/
|
||||||
|
isLockedByUser(address, userId) {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT inbox_address FROM user_locked_inboxes
|
||||||
|
WHERE inbox_address = ? AND user_id = ?
|
||||||
|
`)
|
||||||
|
const result = stmt.get(address.toLowerCase(), userId)
|
||||||
|
return result !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last access timestamp for a locked inbox
|
||||||
|
* @param {number} userId - User ID
|
||||||
|
* @param {string} address - Inbox address
|
||||||
|
*/
|
||||||
|
updateAccess(userId, address) {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
UPDATE user_locked_inboxes
|
||||||
|
SET last_accessed = ?
|
||||||
|
WHERE user_id = ? AND inbox_address = ?
|
||||||
|
`)
|
||||||
|
stmt.run(Date.now(), userId, address.toLowerCase())
|
||||||
|
debug(`Updated last access for inbox ${address} by user ${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inactive locked inboxes (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
|
|
@ -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
|
||||||
161
domain/verification-store.js
Normal 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
|
||||||
122
infrastructure/web/middleware/auth.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
228
infrastructure/web/routes/account.js
Normal 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
|
||||||
249
infrastructure/web/routes/auth.js
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
'/', [
|
'/', [
|
||||||
|
|
|
||||||
170
infrastructure/web/views/account.twig
Normal 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">×</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 %}
|
||||||
124
infrastructure/web/views/auth.twig
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
{% else %}
|
||||||
|
{% if authEnabled %}
|
||||||
|
<a href="/auth" aria-label="Login or Register">Account</a>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/" aria-label="Return to home">Logout</a>
|
|
||||||
|
<a href="/" aria-label="Return to home">Home</a>
|
||||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
||||||
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
|
@ -21,48 +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">×</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 %}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,42 @@
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<div class="action-links">
|
<div class="action-links">
|
||||||
{% if lockEnabled %}
|
{% if currentUser %}
|
||||||
{% if isLocked and hasAccess %}
|
<!-- Inbox Dropdown (multiple actions when logged in) -->
|
||||||
<a href="#" id="removeLockBtn" aria-label="Remove password lock">Remove Lock</a>
|
<div class="action-dropdown">
|
||||||
{% elseif isLocked %}
|
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
|
||||||
<a href="#" id="unlockBtn" aria-label="Unlock inbox">Unlock</a>
|
<div class="dropdown-menu">
|
||||||
{% else %}
|
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
|
||||||
<a href="#" id="lockBtn" aria-label="Protect inbox with password">Protect Inbox</a>
|
{% if authEnabled %}
|
||||||
|
{% if isLocked and hasAccess %}
|
||||||
|
<a href="#" id="removeLockBtn" aria-label="Remove lock">Remove Lock</a>
|
||||||
|
{% elseif not isLocked %}
|
||||||
|
<a href="#" id="lockBtn" aria-label="Lock inbox to your account">Lock Inbox</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Dropdown (logged in) -->
|
||||||
|
{% if authEnabled %}
|
||||||
|
<div class="action-dropdown">
|
||||||
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
|
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<!-- Simple buttons when not logged in -->
|
||||||
|
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
|
||||||
|
{% if authEnabled %}
|
||||||
|
<a href="/auth" aria-label="Login or Register">Account</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
|
|
||||||
{% if lockEnabled and hasAccess %}
|
<a href="/" aria-label="Return to home">Home</a>
|
||||||
<a href="/lock/logout" aria-label="Logout">Logout</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="/logout" aria-label="Logout">Logout</a>
|
|
||||||
{% endif %}
|
|
||||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
||||||
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
|
@ -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">×</span>
|
<span class="close" id="closeLock">×</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">×</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">×</span>
|
<span class="close" id="closeRemoveLock">×</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">×</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 %}
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
{% if currentUser %}
|
||||||
{% if lockEnabled and isLocked and hasAccess %}
|
<!-- Email Dropdown (multiple actions when logged in) -->
|
||||||
<a href="/lock/logout" aria-label="Logout">Logout</a>
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Dropdown (logged in) -->
|
||||||
|
{% if authEnabled %}
|
||||||
|
<div class="action-dropdown">
|
||||||
|
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a href="/account" aria-label="Account settings">Settings</a>
|
||||||
|
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/logout" aria-label="Logout">Logout</a>
|
<!-- Simple buttons when not logged in -->
|
||||||
|
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete</a>
|
||||||
|
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
|
||||||
|
{% if authEnabled %}
|
||||||
|
<a href="/auth" aria-label="Login or Register">Account</a>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<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">×</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 %}
|
||||||
|
|
|
||||||
|
|
@ -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')
|
|
||||||
app.use(session({
|
|
||||||
secret: config.lock.sessionSecret,
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear session when user goes Home so locked inboxes require password again
|
// Session support (always enabled for forward verification and inbox locking)
|
||||||
|
app.use(session({
|
||||||
|
secret: config.user.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||