[Feat]: V2

Updated:
- Update UI,
- Update routes
- Update filters
New:
- Add Password change route
- Add Account deletion button
This commit is contained in:
ClaraCrazy 2026-01-03 16:51:00 +01:00
parent 3fdf5bf61b
commit 2f58eacfa7
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
17 changed files with 1209 additions and 135 deletions

View file

@ -249,7 +249,7 @@ class Helper {
// Warn about old locked-inboxes.db
if (fs.existsSync(legacyLockedInboxesDb)) {
console.log(`⚠️ Found legacy ${legacyLockedInboxesDb}`)
console.log(`WARNING: 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)')

View file

@ -273,7 +273,7 @@ ${mail.html}
<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.
<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>

View file

@ -354,6 +354,100 @@ class UserRepository {
return `${Math.floor(days / 365)} years`
}
/**
* Verify user password
* @param {number} userId - User ID
* @param {string} password - Plain text password to verify
* @returns {Promise<boolean>} - True if password matches
*/
async verifyPassword(userId, password) {
try {
const bcrypt = require('bcrypt')
const stmt = this.db.prepare('SELECT password_hash FROM users WHERE id = ?')
const user = stmt.get(userId)
if (!user) {
debug(`User not found for password verification: ${userId}`)
return false
}
const isValid = await bcrypt.compare(password, user.password_hash)
debug(`Password verification for user ${userId}: ${isValid ? 'success' : 'failed'}`)
return isValid
} catch (error) {
debug(`Error verifying password: ${error.message}`)
return false
}
}
/**
* Update user password
* @param {number} userId - User ID
* @param {string} newPassword - New plain text password
* @returns {Promise<boolean>} - True if successful
*/
async updatePassword(userId, newPassword) {
try {
const bcrypt = require('bcrypt')
const saltRounds = 10
const passwordHash = await bcrypt.hash(newPassword, saltRounds)
const stmt = this.db.prepare(`
UPDATE users
SET password_hash = ?
WHERE id = ?
`)
const result = stmt.run(passwordHash, userId)
if (result.changes > 0) {
debug(`Password updated for user ${userId}`)
return true
} else {
debug(`User not found for password update: ${userId}`)
return false
}
} catch (error) {
debug(`Error updating password: ${error.message}`)
throw error
}
}
/**
* Delete user account and all associated data
* @param {number} userId - User ID
* @returns {boolean} - True if successful
*/
deleteUser(userId) {
try {
// Delete in order due to foreign key constraints:
// 1. forward_emails (references users.id)
// 2. users
const deleteForwardEmails = this.db.prepare('DELETE FROM forward_emails WHERE user_id = ?')
const deleteUser = this.db.prepare('DELETE FROM users WHERE id = ?')
// Use transaction for atomicity
const deleteTransaction = this.db.transaction((uid) => {
deleteForwardEmails.run(uid)
const result = deleteUser.run(uid)
return result.changes > 0
})
const success = deleteTransaction(userId)
if (success) {
debug(`User ${userId} and all associated data deleted`)
} else {
debug(`User ${userId} not found for deletion`)
}
return success
} catch (error) {
debug(`Error deleting user: ${error.message}`)
throw error
}
}
/**
* Close database connection
*/

View file

@ -428,7 +428,8 @@ text-muted {
/* Auth pages */
.auth-container {
max-width: 900px;
min-width: 75%;
max-width: 1500px;
margin: 2rem auto;
padding: 0 1rem;
display: grid;
@ -468,9 +469,10 @@ text-muted {
}
.auth-card small {
margin-top: -10px !important;
font-size: 1.2rem;
display: block;
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
@ -561,7 +563,8 @@ text-muted {
/* Unified auth page (side-by-side login/register) */
.auth-unified-container {
max-width: 1100px;
min-width: 75%;
max-width: 1500px;
margin: 2rem auto;
padding: 0 1rem;
}
@ -581,6 +584,7 @@ text-muted {
grid-template-columns: 1fr 1fr;
gap: 3rem;
margin-bottom: 3rem;
align-items: stretch;
}
@media (max-width: 768px) {
@ -590,6 +594,27 @@ text-muted {
}
}
.auth-card {
display: flex;
flex-direction: column;
}
.auth-card form {
display: flex;
flex-direction: column;
flex: 1;
}
.auth-card fieldset {
display: flex;
flex-direction: column;
flex: 1;
}
.auth-card .button {
margin-top: auto;
}
.auth-card h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
@ -741,7 +766,8 @@ text-muted {
/* Account dashboard page */
.account-container {
max-width: 1200px;
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem 1rem;
}
@ -763,12 +789,14 @@ text-muted {
border: 1px solid var(--color-border-dark);
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
}
.account-card h2 {
color: var(--color-accent-purple);
margin-bottom: 0.5rem;
font-size: 1.5rem;
font-size: 2.5rem;
}
.card-description {
@ -777,7 +805,7 @@ text-muted {
margin-bottom: 1.5rem;
}
.account-stats {
.account-card:nth-child(1) {
grid-column: 1 / -1;
}
@ -910,6 +938,87 @@ form {
margin-top: 1rem;
}
.password-form {
display: flex;
flex-direction: column;
flex: 1;
}
.password-form fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.password-form label {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--color-text-light);
}
.password-form input {
padding: 0.75rem;
border: 1px solid var(--color-border-dark);
border-radius: 5px;
background: var(--color-bg-dark);
color: var(--color-text-primary);
}
.password-form small {
color: var(--color-text-gray);
font-size: 0.85rem;
margin-top: -0.5rem;
}
.password-form .button {
margin-top: auto;
}
.danger-zone {
border: 2px solid var(--color-danger);
}
.danger-zone h2 {
color: var(--color-danger);
}
.danger-content {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.danger-list {
list-style: none;
padding: 0;
margin: 1rem 0 1.5rem 0;
}
.danger-list li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: var(--color-text-primary);
}
.danger-list li::before {
content: '•';
position: absolute;
left: 0;
color: var(--color-danger);
font-weight: bold;
}
.button-full-width {
width: 100%;
}
.danger-content .button {
margin-top: auto;
}
@media (max-width: 768px) {
.account-grid {
grid-template-columns: 1fr;
@ -990,6 +1099,390 @@ select:hover {
background-image: none;
}
/* Frosted Glass Utility Class */
.frosted-glass {
background: linear-gradient(135deg, var(--overlay-white-08), var(--overlay-white-04));
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--overlay-white-15);
border-radius: 24px;
box-shadow: 0 8px 32px var(--overlay-black-40), inset 0 1px 0 var(--overlay-white-10);
}
body.light-mode .frosted-glass {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.5));
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
/* Homepage Styles */
.homepage-container {
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem;
}
.hero-section {
text-align: center;
margin-bottom: 4rem;
padding: 4rem 2rem 2rem;
}
.hero-title {
font-size: 4.5rem;
margin-bottom: 1.5rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.8rem;
color: var(--color-text-dim);
max-width: 700px;
margin: 0 auto;
line-height: 1.6;
}
.inbox-creator {
max-width: 600px;
margin: 0 auto 6rem;
padding: 3rem;
}
.creator-title {
text-align: center;
font-size: 2.4rem;
margin-bottom: 2.5rem;
color: var(--color-text-light);
}
.inbox-form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.form-group label {
font-size: 1.4rem;
font-weight: 600;
color: var(--color-text-light);
padding-left: 0.4rem;
}
.form-group input[type="text"] {
padding: 1.4rem 1.6rem;
font-size: 1.6rem;
border: 1px solid var(--overlay-purple-30);
border-radius: 12px;
background: var(--overlay-white-04);
color: var(--color-text-primary);
transition: all 0.3s ease;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: var(--color-accent-purple-light);
background: var(--overlay-white-06);
box-shadow: 0 0 0 3px var(--overlay-purple-15);
}
.select-wrapper {
position: relative;
}
.select-wrapper::after {
content: '▾';
position: absolute;
right: 1.6rem;
top: 35%;
transform: translateY(-50%);
pointer-events: none;
color: var(--color-text-dim);
font-size: 1.6rem;
}
.form-group select {
padding-left: 1.6rem;
font-size: 1.6rem;
border: 1px solid var(--overlay-purple-30);
border-radius: 12px;
background: var(--overlay-white-04);
color: var(--color-text-primary);
appearance: none;
cursor: pointer;
transition: all 0.3s ease;
width: 100%;
}
.form-group select:hover {
background: var(--overlay-white-06);
border-color: var(--overlay-purple-40);
}
.form-group select:focus {
outline: none;
border-color: var(--color-accent-purple-light);
box-shadow: 0 0 0 3px var(--overlay-purple-15);
}
.form-group select option {
background: var(--color-bg-dark);
color: var(--color-text-primary);
}
.domain-count {
margin-top: -10px;
font-size: 1.2rem;
color: var(--color-text-dimmer);
padding-left: 0.4rem;
}
.form-actions {
display: flex;
gap: 1.2rem;
margin-top: 1rem;
align-items: stretch;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 1.4rem 2.4rem;
font-size: 1.6rem;
font-weight: 600;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
flex: 1;
height: 5.2rem;
box-sizing: border-box;
}
.btn svg {
transition: transform 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--color-accent-purple), var(--color-accent-purple-bright));
color: white;
box-shadow: 0 4px 16px var(--overlay-purple-40);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px var(--overlay-purple-50);
}
.btn-primary:hover svg {
transform: translateX(4px);
}
.btn-secondary {
background: var(--overlay-white-08);
color: var(--color-text-light);
border: 1px solid var(--overlay-purple-25);
}
.btn-secondary:hover {
background: var(--overlay-white-12);
border-color: var(--overlay-purple-35);
transform: translateY(-2px);
}
.btn-secondary:hover svg {
transform: rotate(15deg);
}
.alert {
display: flex;
align-items: center;
gap: 1.2rem;
padding: 1.4rem 1.8rem;
border-radius: 12px;
margin-bottom: 2rem;
}
.alert-success {
background: rgba(46, 204, 113, 0.1);
border: 1px solid #2ecc71;
color: #2ecc71;
}
.alert-error {
background: rgba(176, 0, 0, 0.1);
border: 1px solid var(--color-error);
color: var(--color-error);
}
.alert-warning {
background: var(--overlay-warning-10);
border: 1px solid var(--color-warning);
color: var(--color-warning);
}
.alert span {
font-size: 2rem;
}
.alert p {
margin: 0;
font-size: 1.4rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
margin-bottom: 4rem;
}
.feature-card {
padding: 3rem 2.5rem;
text-align: center;
transition: all 0.3s ease;
}
.feature-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px var(--overlay-black-45), inset 0 1px 0 var(--overlay-white-15);
}
.feature-card h3 {
font-size: 2rem;
margin-bottom: 1rem;
color: var(--color-text-light);
}
.feature-card p {
font-size: 1.4rem;
color: var(--color-text-dim);
line-height: 1.6;
margin: 0;
}
/* Info Section */
.info-section {
margin-top: 5rem;
margin-bottom: 4rem;
}
.info-content {
padding: 4rem 3rem;
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
}
.info-content h2 {
font-size: 3rem;
margin-bottom: 2rem;
color: var(--color-text-light);
text-align: center;
}
.info-content h3 {
font-size: 2.2rem;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: var(--color-accent-purple-light);
}
.info-content p {
font-size: 1.6rem;
line-height: 1.8;
color: var(--color-text-primary);
margin-bottom: 2rem;
}
.info-content ul {
list-style: none;
padding: 0;
margin-bottom: 2rem;
}
.info-content ul li {
font-size: 1.5rem;
line-height: 1.8;
color: var(--color-text-primary);
margin-bottom: 1.5rem;
padding-left: 2rem;
position: relative;
}
.info-content ul li::before {
content: '•';
position: absolute;
left: 0.5rem;
color: var(--color-accent-purple);
font-size: 2rem;
line-height: 1.5;
}
.info-content ul li strong {
color: var(--color-text-light);
}
.info-content .note {
background: var(--overlay-purple-10);
border-left: 4px solid var(--color-accent-purple);
padding: 1.5rem 2rem;
margin-top: 3rem;
font-size: 1.5rem;
line-height: 1.7;
border-radius: 4px;
}
/* Responsive Design */
@media (max-width: 768px) {
.hero-title {
font-size: 3.2rem;
}
.hero-subtitle {
font-size: 1.6rem;
}
.inbox-creator {
padding: 2rem;
}
.form-actions {
flex-direction: column;
}
.features-grid {
grid-template-columns: 1fr;
}
.info-content {
padding: 3rem 2rem;
}
.info-content h2 {
font-size: 2.4rem;
}
.info-content h3 {
font-size: 1.8rem;
}
}
/* Legacy Login Styles (kept for compatibility) */
#login {
padding-top: 15vh;
display: flex;
@ -1596,6 +2089,8 @@ label {
width: 90%;
max-width: 400px;
box-shadow: 0 4px 6px var(--overlay-black-30);
display: flex;
flex-direction: column;
}
.modal-content h3 {
@ -1646,6 +2141,23 @@ label {
color: var(--color-text-primary);
}
.modal-content form {
display: flex;
flex-direction: column;
flex: 1;
}
.modal-content fieldset {
flex: 1;
display: flex;
flex-direction: column;
}
.modal-content .button,
.modal-content .modal-button {
margin-top: auto;
}
.modal-input {
border-radius: 0.4rem;
color: var(--color-text-primary);
@ -1969,7 +2481,8 @@ body.light-mode .theme-icon-light {
/* Statistics Page */
.stats-container {
max-width: 1200px;
min-width: 75%;
max-width: 1500px;
margin: 0 auto;
padding: 2rem;
}
@ -2047,12 +2560,57 @@ body.light-mode .theme-icon-light {
.hamburger-menu {
display: flex;
}
/* Hide dropdowns on mobile, show links directly */
.action-dropdown {
display: contents;
}
.action-dropdown .dropdown-toggle {
display: none;
}
.action-dropdown .dropdown-menu {
display: block;
position: static;
border: none;
border-radius: 0;
box-shadow: none;
padding: 0;
min-width: auto;
background: transparent;
}
/* Add section headers before dropdown menus on mobile */
.action-dropdown .dropdown-menu::before {
content: attr(data-section-title);
display: block;
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-dim);
padding: 12px 0 8px 0;
margin-top: 8px;
border-top: 1px solid var(--overlay-white-10);
}
.action-dropdown:first-of-type .dropdown-menu::before {
margin-top: 0;
border-top: none;
}
.action-dropdown .dropdown-menu a {
padding: 10px 0;
text-align: left;
border: none !important;
}
.action-dropdown .dropdown-menu a:hover {
background: transparent;
color: var(--color-accent-purple-light);
padding-left: 8px;
}
.action-links.mobile-open .theme-toggle {
justify-content: center;
width: 100%;
}
.action-links.mobile-hidden>a,
.action-links.mobile-hidden>button:not(.hamburger-menu) {
.action-links.mobile-hidden>button:not(.hamburger-menu),
.action-links.mobile-hidden>.action-dropdown {
display: none;
}
.action-links.mobile-open {
@ -2066,7 +2624,7 @@ body.light-mode .theme-icon-light {
padding: 15px;
box-shadow: 0 10px 40px var(--overlay-black-40);
z-index: 1000;
min-width: 200px;
min-width: 220px;
}
.action-links.mobile-open>.hamburger-menu {
display: none;
@ -2075,7 +2633,24 @@ body.light-mode .theme-icon-light {
.action-links.mobile-open>button:not(.hamburger-menu) {
display: block;
width: 100%;
text-align: center;
text-align: left;
padding: 10px 0;
border: none;
border-radius: 0;
height: auto;
background: transparent;
font-size: 1.4rem;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
.action-links.mobile-open>a:hover,
.action-links.mobile-open>button:not(.hamburger-menu):hover {
background: transparent;
color: var(--color-accent-purple-light);
transform: none;
box-shadow: none;
padding-left: 8px;
}
.qr-icon-btn {
display: none;

View file

@ -93,8 +93,8 @@ router.post('/account/forward-email/add',
})
// Send verification email
const baseUrl = config.http.baseUrl || 'http://localhost:3000'
const branding = config.http.branding[0] || '48hr.email'
const baseUrl = config.http.baseUrl
const branding = config.http.branding[0]
await smtpService.sendVerificationEmail(
email,
@ -220,4 +220,108 @@ router.post('/account/locked-inbox/release',
}
)
// POST /account/change-password - Change user password
router.post('/account/change-password',
requireAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
body('newPassword').isLength({ min: 8 }).withMessage('New password must be at least 8 characters'),
body('confirmNewPassword').notEmpty().withMessage('Password confirmation is required'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
const { currentPassword, newPassword, confirmNewPassword } = req.body
// Check if new passwords match
if (newPassword !== confirmNewPassword) {
req.session.accountError = 'New passwords do not match'
return res.redirect('/account')
}
// Validate new password strength
const hasUpperCase = /[A-Z]/.test(newPassword)
const hasLowerCase = /[a-z]/.test(newPassword)
const hasNumber = /[0-9]/.test(newPassword)
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
req.session.accountError = 'Password must include uppercase, lowercase, and number'
return res.redirect('/account')
}
const userRepository = req.app.get('userRepository')
// Verify current password
const isValidPassword = await userRepository.verifyPassword(req.session.userId, currentPassword)
if (!isValidPassword) {
req.session.accountError = 'Current password is incorrect'
return res.redirect('/account')
}
// Update password
await userRepository.updatePassword(req.session.userId, newPassword)
req.session.accountSuccess = 'Password updated successfully'
res.redirect('/account')
} catch (error) {
console.error('Change password error:', error)
req.session.accountError = 'Failed to change password. Please try again.'
res.redirect('/account')
}
}
)
// POST /account/delete - Permanently delete user account
router.post('/account/delete',
requireAuth,
body('password').notEmpty().withMessage('Password is required'),
body('confirmText').equals('DELETE').withMessage('You must type DELETE to confirm'),
async(req, res) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
req.session.accountError = errors.array()[0].msg
return res.redirect('/account')
}
const { password } = req.body
const userRepository = req.app.get('userRepository')
// Verify password
const isValidPassword = await userRepository.verifyPassword(req.session.userId, password)
if (!isValidPassword) {
req.session.accountError = 'Incorrect password'
return res.redirect('/account')
}
// Get user's locked inboxes to release them
const inboxLock = req.app.get('inboxLock')
if (inboxLock) {
const lockedInboxes = inboxLock.getUserLockedInboxes(req.session.userId)
for (const inbox of lockedInboxes) {
inboxLock.release(req.session.userId, inbox.address)
}
}
// Delete user account
await userRepository.deleteUser(req.session.userId)
// Destroy session
req.session.destroy((err) => {
if (err) {
console.error('Session destroy error:', err)
}
res.redirect('/?deleted=true')
})
} catch (error) {
console.error('Delete account error:', error)
req.session.accountError = 'Failed to delete account. Please try again.'
res.redirect('/account')
}
}
)
module.exports = router

View file

@ -3,6 +3,11 @@ const router = new express.Router()
const { body, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:auth-routes')
const { redirectIfAuthenticated } = require('../middleware/auth')
const config = require('../../../application/config')
const Helper = require('../../../application/helper')
const helper = new Helper()
const purgeTime = helper.purgeTimeElemetBuilder()
// Simple in-memory rate limiters for registration and login
const registrationRateLimitStore = new Map()
@ -87,6 +92,7 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
res.render('auth', {
title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding,
purgeTime: purgeTime,
errorMessage,
successMessage
})

View file

@ -21,6 +21,7 @@ router.get('/', async(req, res, next) => {
title: `${config.http.branding[0]} | Your temporary Inbox`,
username: randomWord(),
purgeTime: purgeTime,
purgeTimeRaw: config.email.purgeTime,
domains: helper.getDomains(),
branding: config.http.branding,
example: config.email.examples.account,
@ -59,6 +60,7 @@ router.post(
userInputError: true,
title: `${config.http.branding[0]} | Your temporary Inbox`,
purgeTime: purgeTime,
purgeTimeRaw: config.email.purgeTime,
username: randomWord(),
domains: helper.getDomains(),
branding: config.http.branding,

View file

@ -20,20 +20,28 @@
<p class="account-subtitle">Welcome back, <strong>{{ username }}</strong></p>
{% if successMessage %}
<div class="success-message">
{{ successMessage }}
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
<p>{{ successMessage }}</p>
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
<p>{{ errorMessage }}</p>
</div>
{% endif %}
<div class="account-grid">
<!-- Account Stats -->
<div class="account-card account-stats">
<div class="account-card frosted-glass">
<h2>Account Overview</h2>
<div class="stats-grid">
<div class="stat-item">
@ -52,7 +60,7 @@
</div>
<!-- Forwarding Emails Section -->
<div class="account-card">
<div class="account-card frosted-glass">
<h2>Forwarding Emails</h2>
<p class="card-description">Add verified emails to forward messages to. Each email must be verified before use.</p>
@ -85,7 +93,7 @@
</div>
<!-- Locked Inboxes Section -->
<div class="account-card">
<div class="account-card frosted-glass">
<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>
@ -116,6 +124,105 @@
<p class="limit-reached">Maximum {{ stats.maxLockedInboxes }} inboxes locked</p>
{% endif %}
</div>
<!-- Change Password Section -->
<div class="account-card frosted-glass">
<h2>Change Password</h2>
<p class="card-description">Update your account password. You'll need to enter your current password to confirm.</p>
<form method="POST" action="/account/change-password" class="password-form">
<fieldset>
<label for="currentPassword">Current Password</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
placeholder="Enter current password"
required
autocomplete="current-password"
>
<label for="newPassword">New Password</label>
<input
type="password"
id="newPassword"
name="newPassword"
placeholder="Min 8 characters"
required
minlength="8"
autocomplete="new-password"
>
<small>Must include uppercase, lowercase, and number</small>
<label for="confirmNewPassword">Confirm New Password</label>
<input
type="password"
id="confirmNewPassword"
name="confirmNewPassword"
placeholder="Re-enter new password"
required
minlength="8"
autocomplete="new-password"
>
<button type="submit" class="button button-primary">Update Password</button>
</fieldset>
</form>
</div>
<!-- Delete Account Section -->
<div class="account-card frosted-glass danger-zone">
<h2>Danger Zone</h2>
<p class="card-description">Permanently delete your account and all associated data. This action cannot be undone.</p>
<div class="danger-content">
<p><strong>Warning:</strong> Deleting your account will:</p>
<ul class="danger-list">
<li>Remove all forwarding email addresses</li>
<li>Release all locked inboxes</li>
<li>Permanently delete your account data</li>
</ul>
<button class="button button-danger button-full-width" id="deleteAccountBtn">Delete Account</button>
</div>
</div>
</div>
</div>
<!-- Delete Account Modal -->
<div id="deleteAccountModal" class="modal">
<div class="modal-content">
<span class="close" id="closeDeleteAccount">&times;</span>
<h3>Delete Account</h3>
<p class="modal-description" style="color: var(--color-danger);">This action is permanent and cannot be undone!</p>
<form method="POST" action="/account/delete">
<fieldset>
<label for="confirmPassword">Enter your password to confirm</label>
<input
type="password"
id="confirmPassword"
name="password"
placeholder="Your password"
required
class="modal-input"
autocomplete="current-password"
>
<label for="confirmText">Type "DELETE" to confirm</label>
<input
type="text"
id="confirmText"
name="confirmText"
placeholder="Type DELETE"
required
class="modal-input"
>
<button type="submit" class="button button-danger modal-button">Permanently Delete Account</button>
<button type="button" class="button button-secondary modal-button" id="cancelDelete">Cancel</button>
</fieldset>
</form>
</div>
</div>
@ -161,10 +268,37 @@ if (closeAddEmail) {
}
}
// Delete Account Modal
const deleteAccountBtn = document.getElementById('deleteAccountBtn');
const deleteAccountModal = document.getElementById('deleteAccountModal');
const closeDeleteAccount = document.getElementById('closeDeleteAccount');
const cancelDelete = document.getElementById('cancelDelete');
if (deleteAccountBtn) {
deleteAccountBtn.onclick = function() {
deleteAccountModal.style.display = 'block';
}
}
if (closeDeleteAccount) {
closeDeleteAccount.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
if (cancelDelete) {
cancelDelete.onclick = function() {
deleteAccountModal.style.display = 'none';
}
}
window.onclick = function(event) {
if (event.target == addEmailModal) {
addEmailModal.style.display = 'none';
}
if (event.target == deleteAccountModal) {
deleteAccountModal.style.display = 'none';
}
}
</script>
{% endblock %}

View file

@ -20,50 +20,29 @@
<h1 class="page-title">Account Access</h1>
<p class="auth-subtitle">Login to an existing account or create a new one</p>
{% if errorMessage %}
<div class="unlock-error">{{ errorMessage }}</div>
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
{% endif %}
{% if successMessage %}
<div class="success-message">{{ successMessage }}</div>
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
{{ 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">
<div class="auth-card frosted-glass">
<h2>Register</h2>
<p class="auth-card-subtitle">Create a new account</p>
<form method="POST" action="/register">
<fieldset>
@ -108,10 +87,41 @@
</fieldset>
</form>
</div>
<!-- Login Form -->
<div class="auth-card frosted-glass">
<h2>Login</h2>
<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>
</div>
<div class="auth-features-unified">
<h3>✓ Account Benefits</h3>
<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>

View file

@ -7,7 +7,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
</div>

View file

@ -6,7 +6,7 @@
<!-- Inbox Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Inbox Actions">
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
{% if authEnabled %}
{% if isLocked and hasAccess %}
@ -23,7 +23,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect={{ ('/inbox/' ~ address) | url_encode }}" aria-label="Logout">Logout</a>
</div>
@ -53,8 +53,11 @@
<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>
{% if forwardAllSuccess %}
<div class="success-message">
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
Successfully forwarded {{ forwardAllSuccess }} email(s)!
</div>
{% endif %}
{% if verificationSent %}
@ -63,7 +66,12 @@
</div>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
<div class="alert alert-error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
{% endif %}
@ -84,7 +92,7 @@
<div class="emails-container">
{% for mail in mailSummaries %}
<a href="{{ mail.to[0] }}/{{ mail.uid }}" class="email-link">
<div class="email-card">
<div class="email-card frosted-glass">
<div class="email-header">
<div class="email-sender">
<div class="sender-name">{{ mail.from[0].name }}</div>
@ -111,20 +119,20 @@
{% if authEnabled and not isLocked %}
<!-- Lock Modal -->
<div id="lockModal" class="modal" style="display: none;" data-lock-error="{{ error|default('') }}">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeLock">&times;</span>
<h3>Lock Inbox</h3>
<p class="modal-description">Lock this inbox to your account. Only you will be able to access it while logged in.</p>
{% if error and error == 'locking_disabled_for_example' %}
<p id="lockServerError" class="unlock-error">Locking is disabled for the example inbox.</p>
<p id="lockServerError" class="alert alert-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>
<p id="lockServerError" class="alert alert-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>
<p id="lockServerError" class="alert alert-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>
<p id="lockServerError" class="alert alert-error">You don't own the lock on this inbox.</p>
{% endif %}
<p id="lockErrorInline" class="unlock-error" style="display:none"></p>
<p id="lockErrorInline" class="alert alert-error" style="display:none"></p>
<form method="POST" action="/lock/lock">
<input type="hidden" name="address" value="{{ address }}">
<fieldset>
@ -142,7 +150,7 @@
{% if authEnabled and isLocked and hasAccess %}
<!-- Remove Lock Modal -->
<div id="removeLockModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeRemoveLock">&times;</span>
<h3>Remove Lock</h3>
<p class="modal-description">Are you sure you want to remove the lock from this inbox? Anyone will be able to access it.</p>

View file

@ -7,7 +7,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/" aria-label="Logout">Logout</a>
</div>
@ -32,33 +32,105 @@
{% endblock %}
{% block body %}
<div id="login">
<h1 class="page-title">Welcome!</h1>
<h4>Here you can either create a new Inbox, or access your old one</h4>
<div class="homepage-container">
<div class="hero-section">
<h1 class="page-title hero-title">Your Temporary Inbox</h1>
<p class="hero-subtitle">Create instant disposable email addresses. No registration required. Emails auto-delete after {{ purgeTimeRaw|readablePurgeTime }}.</p>
</div>
<div class="inbox-creator frosted-glass">
<h2 class="creator-title">Get Started</h2>
{% if userInputError %}
<blockquote class="warning">
<div class="alert alert-warning">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Your input was invalid. Please try other values.
</blockquote>
</div>
{% endif %}
<form method="POST" action="/">
<fieldset>
<label for="nameField">Name</label>
<input type="text" id="nameField" name="username" value="{{ username }}">
<label for="commentField">Domain ({{ domains|length }})</label>
<div class="dropdown">
<form method="POST" action="/" class="inbox-form">
<div class="form-group">
<label for="nameField">Choose Your Name</label>
<input type="text" id="nameField" name="username" value="{{ username }}" placeholder="e.g., john.doe" required>
</div>
<div class="form-group">
<label for="commentField">Select Domain</label>
<div class="select-wrapper">
<select id="commentField" name="domain">
{% for domain in domains %}
<option value="{{ domain }}">{{ domain }}</option>
<option value="{{ domain }}">@{{ domain }}</option>
{% endfor %}
</select>
</div>
<div class="buttons">
<input class="button" type="submit" value="Access This Inbox">
<a class="button" href="/inbox/random">Create Random Inbox</a>
<span class="domain-count">{{ domains|length }} domains available</span>
</div>
</fieldset>
</form>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<span>Access Inbox</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
<a href="/inbox/random" class="btn btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Random Inbox</span>
</a>
</div>
</form>
</div>
<div class="features-grid">
<div class="feature-card frosted-glass">
<h3>Privacy First</h3>
<p>No registration, no tracking. Your temporary email is completely anonymous.</p>
</div>
<div class="feature-card frosted-glass">
<h3>Instant Access</h3>
<p>Create unlimited temporary email addresses in seconds. No waiting, no verification.</p>
</div>
<div class="feature-card frosted-glass">
<h3>Auto-Delete</h3>
<p>All emails automatically purge after {{ purgeTimeRaw|readablePurgeTime }}. Clean and secure by default.</p>
</div>
</div>
<div class="info-section">
<div class="info-content frosted-glass">
<h2>What is a Temporary Email?</h2>
<p>A temporary email (also known as disposable email or throwaway email) is a service that allows you to receive emails at a temporary address that self-destructs after a certain time. It's perfect for signing up to websites, testing services, or protecting your real inbox from spam.</p>
<h3>Common Use Cases</h3>
<ul class="use-cases-list">
<li><strong>Avoid Spam:</strong> Sign up for services without cluttering your primary inbox</li>
<li><strong>Test Services:</strong> Try new apps and websites without commitment</li>
<li><strong>Online Privacy:</strong> Keep your real email address private from third parties</li>
<li><strong>One-Time Verification:</strong> Receive verification codes without long-term exposure</li>
<li><strong>Download Content:</strong> Access gated content that requires email confirmation</li>
<li><strong>Form Testing:</strong> Test email workflows during development</li>
</ul>
<h3>Why Choose {{ branding[0] }}?</h3>
<ul class="benefits-list">
<li><strong>No Sign-Up Required:</strong> Start using immediately without creating an account</li>
<li><strong>Multiple Domains:</strong> Choose from several domain options for flexibility</li>
<li><strong>Clean Interface:</strong> Simple, modern design focused on usability</li>
<li><strong>Real-Time Updates:</strong> See new emails arrive instantly without refreshing</li>
<li><strong>Open Source:</strong> Transparent codebase you can review and trust</li>
<li><strong>{{ purgeTimeRaw|readablePurgeTime|title }} Retention:</strong> Emails stay accessible for the full duration before auto-deletion</li>
</ul>
<p class="note">For extended features like email forwarding and inbox locking, you can optionally create a free account. But for basic temporary email needs, no registration is ever required.</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@
<!-- Email Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Email Actions">
<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>
@ -19,7 +19,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect={{ ('/inbox/' ~ address ~ '/' ~ uid) | url_encode }}" aria-label="Logout">Logout</a>
</div>
@ -48,8 +48,11 @@
{% block body %}
{% if forwardSuccess %}
<div class="success-message">
✓ Email forwarded successfully!
<div class="alert alert-success">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;vertical-align:middle;margin-right:0.5rem">
<path d="M20 6L9 17l-5-5"></path>
</svg>
Email forwarded successfully!
</div>
{% endif %}
{% if verificationSent %}
@ -125,7 +128,7 @@
<!-- Forward Email Modal -->
<div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-content frosted-glass">
<span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3>
@ -143,9 +146,9 @@
{% else %}
<p class="modal-description">Select a verified email address to forward this message to.</p>
{% if errorMessage %}
<p class="unlock-error">{{ errorMessage }}</p>
<p class="alert alert-error">{{ errorMessage }}</p>
{% endif %}
<p id="forwardError" class="unlock-error" style="display:none"></p>
<p id="forwardError" class="alert alert-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
<fieldset>
<label for="forwardEmail" class="floating-label">Forward to</label>

View file

@ -7,7 +7,7 @@
{% if authEnabled %}
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Account menu">Account ▾</button>
<div class="dropdown-menu">
<div class="dropdown-menu" data-section-title="Account">
<a href="/account" aria-label="Account settings">Settings</a>
<a href="/logout?redirect=/stats" aria-label="Logout">Logout</a>
</div>

View file

@ -1,4 +1,5 @@
const sanitizeHtml = require('sanitize-html')
const config = require('../../../application/config')
/**
* Transformes <a> tags to always use "noreferrer noopener" and open in a new window.
@ -25,3 +26,65 @@ exports.sanitizeHtmlTwigFilter = function(value) {
}
})
}
/**
* Convert time to highest possible unit (minutes hours days),
* rounding if necessary and prefixing "~" when rounded.
* Mirrors the logic from Helper.convertAndRound()
*
* @param {number} time
* @param {string} unit "minutes" | "hours" | "days"
* @returns {string}
*/
function convertAndRound(time, unit) {
let value = time
let u = unit
// upgrade units
const units = [
["minutes", 60, "hours"],
["hours", 24, "days"]
]
for (const [from, factor, to] of units) {
if (u === from && value > factor) {
value = value / factor
u = to
}
}
// determine if rounding is needed
const rounded = !Number.isSafeInteger(value)
if (rounded) value = Math.round(value)
// Handle singular/plural
const displayValue = value === 1 ? value : value
const displayUnit = value === 1 ? u.replace(/s$/, '') : u
return `${rounded ? "~" : ""}${displayValue} ${displayUnit}`
}
/**
* Convert purgeTime config to readable format, respecting the convert flag
* @param {Object} purgeTime - Object with time, unit, and convert properties
* @returns {String} Readable time string
*/
exports.readablePurgeTime = function(purgeTime) {
if (!purgeTime || !purgeTime.time || !purgeTime.unit) {
// Fallback to config if not provided
if (config.email.purgeTime) {
purgeTime = config.email.purgeTime
} else {
return '48 hours'
}
}
let result = `${purgeTime.time} ${purgeTime.unit}`
// Only convert if the convert flag is true
if (purgeTime.convert) {
result = convertAndRound(purgeTime.time, purgeTime.unit)
}
return result
}

View file

@ -18,7 +18,7 @@ const lockRouter = require('./routes/lock')
const authRouter = require('./routes/auth')
const accountRouter = require('./routes/account')
const statsRouter = require('./routes/stats')
const { sanitizeHtmlTwigFilter } = require('./views/twig-filters')
const { sanitizeHtmlTwigFilter, readablePurgeTime } = require('./views/twig-filters')
const Helper = require('../../application/helper')
const helper = new(Helper)
@ -90,6 +90,7 @@ app.use(
})
)
Twig.extendFilter('sanitizeHtml', sanitizeHtmlTwigFilter)
Twig.extendFilter('readablePurgeTime', readablePurgeTime)
// Middleware to expose user session to all templates
app.use((req, res, next) => {

View file

@ -1,6 +1,6 @@
{
"name": "48hr.email",
"version": "1.9.0",
"version": "2.0.0",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [
@ -68,7 +68,8 @@
}
]
},
"overrides": [{
"overrides": [
{
"files": "public/javascripts/*.js",
"esnext": false,
"env": [
@ -77,6 +78,7 @@
"globals": [
"io"
]
}]
}
]
}
}