[Feat]: Update Config structure, add more feature flags, fix 302s

This is what makes a project a "Clara project"... going the extra mile for customizability <3
This commit is contained in:
ClaraCrazy 2026-01-05 04:50:53 +01:00
parent d454f91912
commit 345935f8b9
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
22 changed files with 435 additions and 97 deletions

View file

@ -27,13 +27,14 @@ IMAP_CONCURRENCY=6 # Number of conc
SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false)
SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address)
SMTP_PASSWORD="password" # SMTP authentication password
SMTP_SERVER="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net)
SMTP_SERVER="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)
# --- HTTP / WEB CONFIGURATION ---
HTTP_PORT=3000 # Port
HTTP_BASE_URL="http://localhost:3000" # Base URL for verification links (e.g., https://48hr.email)
HTTP_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url']
HTTP_DISPLAY_SORT=2 # Domain display sorting:
# 0 = no change,
@ -42,12 +43,11 @@ HTTP_DISPLAY_SORT=2 # Domain display
# 3 = shuffle all
HTTP_HIDE_OTHER=false # true = only show first domain, false = show all
HTTP_STATISTICS_ENABLED=false # Enable statistics page at /stats (true/false)
HTTP_SHOW_INFO_SECTION=true # Show info section on homepage (true/false)
# --- USER AUTHENTICATION & INBOX LOCKING ---
USER_AUTH_ENABLED=false # Enable user registration/login system (default: false)
USER_SESSION_SECRET="change-this-secret-in-production" # Secret for session encryption (used for auth & locking)
USER_DATABASE_PATH="./db/data.db" # Path to application database (users, forwarding, locks)
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)

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.env
.env.backup
.idea
.vscode
.DS_Store

17
app.js
View file

@ -2,6 +2,22 @@
/* eslint unicorn/no-process-exit: 0 */
// Check .env file permissions before loading config
const fs = require('fs')
const path = require('path')
const envPath = path.resolve('.env')
if (fs.existsSync(envPath)) {
const mode = fs.statSync(envPath).mode
const perms = (mode & parseInt('777', 8)).toString(8)
const groupReadable = parseInt(perms[1], 10) >= 4
const otherReadable = parseInt(perms[2], 10) >= 4
if (groupReadable || otherReadable) {
console.error(`\nSECURITY ERROR: .env file has insecure permissions (${perms})`)
console.error(`Run: chmod 600 ${envPath}\n`)
process.exit(1)
}
}
const config = require('./application/config')
const debug = require('debug')('48hr-email:app')
const Helper = require('./application/helper')
@ -90,6 +106,7 @@ if (config.user.authEnabled) {
const imapService = new ImapService(config, inboxLock)
debug('IMAP service initialized')
app.set('imapService', imapService)
const mailProcessingService = new MailProcessingService(
new MailRepository(),

View file

@ -2,6 +2,13 @@
require("dotenv").config({ quiet: true });
const debug = require('debug')('48hr-email:config')
// Migration helper: warn about deprecated env vars
if (process.env.USER_SESSION_SECRET && !process.env.HTTP_SESSION_SECRET) {
console.warn('\nDEPRECATION WARNING: USER_SESSION_SECRET is deprecated.')
console.warn(' Please rename it to HTTP_SESSION_SECRET in your .env file.')
console.warn(' The old name still works but will be removed in a future version.\n')
}
/**
* Safely parse a value from env.
* Returns `undefined` if the value is missing or invalid.
@ -31,7 +38,7 @@ function parseBool(v) {
const config = {
email: {
domains: parseValue(process.env.EMAIL_DOMAINS),
domains: parseValue(process.env.EMAIL_DOMAINS) || [],
purgeTime: {
time: Number(process.env.EMAIL_PURGE_TIME),
unit: parseValue(process.env.EMAIL_PURGE_UNIT),
@ -41,7 +48,16 @@ const config = {
account: parseValue(process.env.EMAIL_EXAMPLE_ACCOUNT),
uids: parseValue(process.env.EMAIL_EXAMPLE_UIDS)
},
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || []
blacklistedSenders: parseValue(process.env.EMAIL_BLACKLISTED_SENDERS) || [],
features: {
imap: {
enabled: true, // IMAP is always required
refreshIntervalSeconds: Number(process.env.IMAP_REFRESH_INTERVAL_SECONDS),
fetchChunkSize: Number(process.env.IMAP_FETCH_CHUNK) || 100,
fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6
},
smtp: parseBool(process.env.SMTP_ENABLED) || false
}
},
imap: {
@ -66,12 +82,19 @@ const config = {
},
http: {
// Server settings
port: Number(process.env.HTTP_PORT),
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
branding: parseValue(process.env.HTTP_BRANDING),
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
statisticsEnabled: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false
sessionSecret: parseValue(process.env.HTTP_SESSION_SECRET) || parseValue(process.env.USER_SESSION_SECRET) || 'change-me-in-production',
// UI Features & Display
features: {
branding: parseValue(process.env.HTTP_BRANDING),
displaySort: Number(process.env.HTTP_DISPLAY_SORT) || 0,
hideOther: parseBool(process.env.HTTP_HIDE_OTHER),
statistics: parseBool(process.env.HTTP_STATISTICS_ENABLED) || false,
infoSection: parseBool(process.env.HTTP_SHOW_INFO_SECTION) !== false // default true
}
},
user: {
@ -81,9 +104,6 @@ const config = {
// 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,

View file

@ -109,11 +109,10 @@ class Helper {
/**
* Build a mail count html element with tooltip for the footer
* @param {number} count - Current mail count
* @param {number} largestUid - Largest UID from IMAP
* @returns {String}
*/
mailCountBuilder(count) {
const imapService = require('./imap-service')
const largestUid = imapService.getLargestUid ? imapService.getLargestUid() : null
mailCountBuilder(count, largestUid = null) {
let tooltip = ''
if (largestUid && largestUid > 0) {
@ -156,10 +155,10 @@ class Helper {
* @returns {Array}
*/
hideOther(array) {
if (config.http.hideOther) {
return array[0]
if (config.http.features.hideOther) {
return array && array.length > 0 ? [array[0]] : []
} else {
return array
return array || []
}
}
@ -168,9 +167,13 @@ class Helper {
* @returns {Array}
*/
getDomains() {
debug(`Getting domains with displaySort: ${config.http.displaySort}`)
debug(`Getting domains with displaySort: ${config.http.features.displaySort}`)
if (!config.email.domains || !Array.isArray(config.email.domains) || !config.email.domains.length) {
debug('ERROR: config.email.domains is not a valid array')
return []
}
let result;
switch (config.http.displaySort) {
switch (config.http.features.displaySort) {
case 0:
result = this.hideOther(config.email.domains) // No modification
debug(`Domain sort 0: no modification, ${result.length} domains`)
@ -187,6 +190,9 @@ class Helper {
result = this.hideOther(this.shuffleArray(config.email.domains)) // Shuffle all
debug(`Domain sort 3: shuffle all, ${result.length} domains`)
return result
default:
debug(`Unknown displaySort value: ${config.http.features.displaySort}, using case 0`)
return this.hideOther(config.email.domains)
}
}
@ -212,7 +218,7 @@ class Helper {
* @returns {string} - HMAC signature (hex)
*/
signCookie(email) {
const secret = config.user.sessionSecret
const secret = config.http.sessionSecret
const hmac = crypto.createHmac('sha256', secret)
hmac.update(email.toLowerCase())
const signature = hmac.digest('hex')

View file

@ -274,7 +274,7 @@ class MailProcessingService extends EventEmitter {
// Forward via SMTP service
debug(`Forwarding email to ${destinationEmail}`)
const branding = this.config.http.branding[0] || '48hr.email'
const branding = this.config.http.features.branding[0] || '48hr.email'
const result = await this.smtpService.forwardMail(fullMail, destinationEmail, branding)
if (result.success) {
@ -354,7 +354,7 @@ class MailProcessingService extends EventEmitter {
// Send verification email
const baseUrl = this.config.http.baseUrl
const branding = this.config.http.branding[0] || '48hr.email'
const branding = this.config.http.features.branding[0] || '48hr.email'
debug(`Sending verification email to ${destinationEmail} for source ${sourceAddress}`)
const result = await this.smtpService.sendVerificationEmail(

View file

@ -2887,7 +2887,7 @@ body.light-mode .theme-icon-light {
}
.action-dropdown .dropdown-menu a {
padding: 10px 0;
text-align: left;
text-align: right;
border: none !important;
}
.action-dropdown .dropdown-menu a:hover {
@ -2924,7 +2924,7 @@ body.light-mode .theme-icon-light {
.action-links.mobile-open>button:not(.hamburger-menu) {
display: block;
width: 100%;
text-align: left;
text-align: right;
padding: 10px 0;
border: none;
border-radius: 0;

View file

@ -35,7 +35,7 @@ router.get('/account', requireAuth, async(req, res) => {
forwardEmails,
lockedInboxes,
stats,
branding: config.http.branding,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime,
successMessage: req.session.accountSuccess,
errorMessage: req.session.accountError
@ -94,7 +94,7 @@ router.post('/account/forward-email/add',
// Send verification email
const baseUrl = config.http.baseUrl
const branding = config.http.branding[0]
const branding = (config.http.features.branding || ['48hr.email'])[0]
await smtpService.sendVerificationEmail(
email,

View file

@ -79,6 +79,20 @@ const loginRateLimiter = (req, res, next) => {
next()
}
// Middleware to capture redirect URL
router.use((req, res, next) => {
if (req.method === 'GET' && req.path === '/auth') {
const referer = req.get('Referer')
const redirectParam = req.query.redirect
const redirectUrl = redirectParam || (referer && !referer.includes('/auth') ? referer : null)
if (redirectUrl) {
req.session.redirectAfterLogin = redirectUrl
}
}
next()
})
// GET /auth - Show unified auth page (login or register)
router.get('/auth', redirectIfAuthenticated, (req, res) => {
const config = req.app.get('config')
@ -90,8 +104,8 @@ router.get('/auth', redirectIfAuthenticated, (req, res) => {
delete req.session.successMessage
res.render('auth', {
title: `Login or Register | ${config.http.branding[0]}`,
branding: config.http.branding,
title: `Login or Register | ${(config.http.features.branding || ['48hr.email'])[0]}`,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: purgeTime,
errorMessage,
successMessage

View file

@ -19,14 +19,15 @@ router.get('/:address/:errorCode', async(req, res, next) => {
const message = req.query.message || (req.session && req.session.errorMessage) || 'An error occurred'
debug(`Rendering error page ${errorCode} with message: ${message}`)
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
res.status(errorCode)
res.render('error', {
title: `${config.http.branding[0]} | ${errorCode}`,
title: `${branding[0]} | ${errorCode}`,
purgeTime: purgeTime,
address: req.params.address,
message: message,
status: errorCode,
branding: config.http.branding
branding: branding
})
} catch (error) {
debug('Error loading error page:', error.message)
@ -36,4 +37,4 @@ router.get('/:address/:errorCode', async(req, res, next) => {
}
})
module.exports = router
module.exports = router

View file

@ -144,12 +144,13 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, optiona
const verificationEmail = req.query.email || ''
res.render('inbox', {
title: `${config.http.branding[0]} | ` + req.params.address,
title: `${(config.http.features.branding || ['48hr.email'])[0]} | ` + req.params.address,
purgeTime: purgeTime,
address: req.params.address,
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
branding: config.http.branding,
authEnabled: config.user.authEnabled,
smtpEnabled: config.email.features.smtp,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
@ -240,8 +241,9 @@ router.get(
mail,
cryptoAttachments: cryptoAttachments,
uid: req.params.uid,
branding: config.http.branding,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
authEnabled: config.user.authEnabled,
smtpEnabled: config.email.features.smtp,
isAuthenticated: req.session && req.session.userId ? true : false,
userForwardEmails: userForwardEmails,
isLocked: isLocked,
@ -440,10 +442,21 @@ router.get(
}
)
// Middleware to check if SMTP is enabled
const smtpEnabled = (req, res, next) => {
if (!config.email.features.smtp) {
debug('SMTP forwarding is disabled')
req.session.errorMessage = 'Email forwarding is currently disabled.'
return res.redirect(`/inbox/${req.params.address}` + (req.params.uid ? `/${req.params.uid}` : ''))
}
next()
}
// POST route for forwarding a single email (requires authentication)
router.post(
'^/:address/:uid/forward',
requireAuth,
smtpEnabled,
forwardLimiter,
validateDomain,
checkLockAccess,
@ -503,6 +516,7 @@ router.post(
router.post(
'^/:address/forward-all',
requireAuth,
smtpEnabled,
forwardLimiter,
validateDomain,
checkLockAccess,

View file

@ -2,13 +2,9 @@ const express = require('express')
const router = new express.Router()
const { check, validationResult } = require('express-validator')
const debug = require('debug')('48hr-email:routes')
const randomWord = require('random-word')
const config = require('../../../application/config')
const Helper = require('../../../application/helper')
const helper = new(Helper)
const purgeTime = helper.purgeTimeElemetBuilder()
const templateContext = require('../template-context')
router.get('/', async(req, res, next) => {
try {
@ -17,14 +13,12 @@ router.get('/', async(req, res, next) => {
throw new Error('Mail processing service not available')
}
debug('Login page requested')
const context = templateContext.build(req, {
username: randomWord()
})
res.render('login', {
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,
...context,
title: `${context.branding[0]} | Your temporary Inbox`
})
} catch (error) {
debug('Error loading login page:', error.message)
@ -56,14 +50,13 @@ router.post(
const errors = validationResult(req)
if (!errors.isEmpty()) {
debug(`Login validation failed for ${req.body.username}@${req.body.domain}: ${errors.array().map(e => e.msg).join(', ')}`)
return res.render('login', {
const context = templateContext.build(req, {
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,
username: randomWord()
})
return res.render('login', {
...context,
title: `${context.branding[0]} | Your temporary Inbox`
})
}

View file

@ -8,8 +8,12 @@ router.get('/', async(req, res) => {
const config = req.app.get('config')
// Check if statistics are enabled
if (!config.http.statisticsEnabled) {
return res.status(404).send('Statistics are disabled')
if (!config.http.features.statistics) {
req.session.alertMessage = 'Statistics are disabled'
const referer = req.get('Referer')
// Don't redirect to /stats itself to avoid infinite loops
const redirectUrl = (referer && !referer.includes('/stats')) ? referer : '/'
return res.redirect(redirectUrl)
}
const statisticsStore = req.app.get('statisticsStore')
@ -33,12 +37,13 @@ router.get('/', async(req, res) => {
const stats = statisticsStore.getEnhancedStats()
const purgeTime = helper.purgeTimeElemetBuilder()
const branding = config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
debug(`Stats page requested: ${stats.currentCount} current, ${stats.allTimeTotal} all-time total, ${stats.historical.length} historical points`)
res.render('stats', {
title: `Statistics | ${config.http.branding[0]}`,
branding: config.http.branding,
title: `Statistics | ${branding[0]}`,
branding: branding,
purgeTime: purgeTime,
stats: stats,
authEnabled: config.user.authEnabled,

View file

@ -0,0 +1,57 @@
const config = require('../../application/config')
const Helper = require('../../application/helper')
/**
* Template Context Builder
* Generates common variables for all template renders
*/
class TemplateContext {
constructor() {
this.helper = new Helper()
this.purgeTime = this.helper.purgeTimeElemetBuilder()
}
/**
* Get base context that should be available in all templates
* @param {Object} req - Express request object
* @returns {Object} Base template context
*/
getBaseContext(req) {
return {
// Config values
config: config,
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com'],
purgeTime: this.purgeTime,
purgeTimeRaw: config.email.purgeTime,
// Feature flags
authEnabled: config.user.authEnabled,
statisticsEnabled: config.http.features.statistics,
smtpEnabled: config.email.features.smtp,
showInfoSection: config.http.features.infoSection,
// User session
currentUser: req.session && req.session.username ? req.session.username : null,
// Common data
domains: this.helper.getDomains(),
example: config.email.examples.account
}
}
/**
* Merge base context with page-specific data
* @param {Object} req - Express request object
* @param {Object} pageData - Page-specific template data
* @returns {Object} Complete template context
*/
build(req, pageData = {}) {
return {
...this.getBaseContext(req),
...pageData
}
}
}
// Export singleton instance
module.exports = new TemplateContext()

View file

@ -4,22 +4,22 @@
<div class="action-links">
{% if currentUser %}
<!-- Inbox Dropdown (multiple actions when logged in) -->
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu" data-section-title="Inbox Actions">
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</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>
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Inbox actions">Inbox ▾</button>
<div class="dropdown-menu" data-section-title="Inbox Actions">
{% if smtpEnabled %}
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</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 %}
{% 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>
@ -178,6 +178,7 @@
</div>
<!-- Forward All Modal -->
{% if smtpEnabled %}
<div id="forwardAllModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span>
@ -216,4 +217,5 @@
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -88,13 +88,20 @@
</a>
{% block header %}{% endblock %}
</div>
{% if alertMessage %}
<div class="container" style="margin-top: 1rem;">
<div class="alert alert-warning" style="font-size: 0.85rem;">
{{ alertMessage }}
</div>
</div>
{% endif %}
{% block body %}{% endblock %}
</main>
{% block footer %}
<section class="container footer">
<hr>
{% if config.http.statisticsEnabled %}
{% if config.http.features.statistics %}
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Check out our public <a href="/stats" style="text-decoration:underline">Statistics</a></h4>
{% else %}
<h4>{{ branding[0] }} offered by <a href="{{ branding[2] }}" style="text-decoration:underline" target="_blank">{{ branding[1] }}</a> | All Emails will be deleted after {{ purgeTime | raw }} | Currently handling {{ mailCount | raw }}</h4>

View file

@ -89,22 +89,27 @@
</form>
</div>
<div class="features-grid">
<div class="feature-card frosted-glass">
<h3>Privacy First</h3>
<p>No tracking, no bullshit. 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>
{% if showInfoSection %}
<div class="info-section">
<div class="features-grid">
<div class="feature-card frosted-glass">
<h3>Privacy First</h3>
{% if statisticsEnabled %}
<p>No tracking, no personal data collection. We only store email metadata for system analytics.</p>
{% else %}
<p>No tracking, no data collection. Your temporary email is completely anonymous.</p>
{% endif %}
</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-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>
@ -129,8 +134,9 @@
<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>
<p class="note">{% if smtpEnabled or authEnabled %}For extended features like{% if smtpEnabled %} email forwarding{% endif %}{% if smtpEnabled and authEnabled %} and{% endif %}{% if authEnabled %} inbox locking{% endif %}, you can optionally create a free account. {% endif %}For basic temporary email needs, no registration is ever required.</p>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -9,7 +9,9 @@
<div class="action-dropdown">
<button class="dropdown-toggle" aria-label="Email actions">Email ▾</button>
<div class="dropdown-menu" data-section-title="Email Actions">
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
{% if smtpEnabled %}
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward</a>
{% endif %}
<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>
@ -127,6 +129,7 @@
</div>
<!-- Forward Email Modal -->
{% if smtpEnabled %}
<div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content frosted-glass">
<span class="close" id="closeForward">&times;</span>
@ -165,6 +168,7 @@
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -45,11 +45,11 @@ app.use(express.json())
app.use(express.urlencoded({ extended: false }))
// Cookie parser for signed cookies (email verification)
app.use(cookieParser(config.user.sessionSecret))
app.use(cookieParser(config.http.sessionSecret))
// Session support (always enabled for forward verification and inbox locking)
app.use(session({
secret: config.user.sessionSecret,
secret: config.http.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
@ -97,6 +97,13 @@ app.use((req, res, next) => {
res.locals.authEnabled = config.user.authEnabled
res.locals.config = config
res.locals.currentUser = null
res.locals.alertMessage = req.session ? req.session.alertMessage : null
// Clear alert after reading
if (req.session && req.session.alertMessage) {
delete req.session.alertMessage
}
if (req.session && req.session.userId && req.session.username && req.session.isAuthenticated) {
res.locals.currentUser = {
id: req.session.userId,
@ -107,14 +114,25 @@ app.use((req, res, next) => {
})
// Middleware to expose mail count to all templates
app.use((req, res, next) => {
app.use(async(req, res, next) => {
const mailProcessingService = req.app.get('mailProcessingService')
const imapService = req.app.get('imapService')
const Helper = require('../../application/helper')
const helper = new Helper()
if (mailProcessingService) {
const count = mailProcessingService.getCount()
res.locals.mailCount = helper.mailCountBuilder(count)
let largestUid = null
if (imapService) {
try {
largestUid = await imapService.getLargestUid()
} catch (e) {
debug('Error getting largest UID:', e.message)
}
}
res.locals.mailCount = helper.mailCountBuilder(count, largestUid)
} else {
res.locals.mailCount = ''
}
@ -159,7 +177,7 @@ app.use(async(err, req, res, _next) => {
res.render('error', {
purgeTime: purgeTime,
address: req.params && req.params.address,
branding: config.http.branding
branding: config.http.features.branding || ['48hr.email', 'Service', 'https://example.com']
})
} catch (renderError) {
debug('Error in error handler:', renderError.message)

View file

@ -1,6 +1,6 @@
{
"name": "48hr.email",
"version": "2.1.0",
"version": "2.2.0",
"private": false,
"description": "48hr.email is your favorite open-source tempmail client.",
"keywords": [
@ -27,7 +27,8 @@
"scripts": {
"start": "node --trace-warnings ./app.js",
"debug": "DEBUG=48hr-email:* node --nolazy --inspect-brk=9229 --trace-warnings ./app.js",
"test": "xo"
"test": "xo",
"env:check": "node scripts/check-env.js"
},
"dependencies": {
"async-retry": "^1.3.3",

22
scripts/check-domains.js Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
// Test script to verify domains are loaded correctly
const helper = new Helper()
const domains = helper.getDomains()
console.log('\nDomains from helper.getDomains():', domains)
console.log('Length:', domains ? domains.length : undefined)
console.log('Type:', typeof config.email.domains)
console.log('Is Array:', Array.isArray(config.email.domains))
console.log('Length:', config.email.domains ? config.email.domains.length : undefined)
if (Array.isArray(config.email.domains) && config.email.domains.length > 0) {
console.log('\nDomains list:')
config.email.domains.forEach((d, i) => console.log(` ${i + 1}. ${d}`))
} else {
console.log('\nERROR: No domains configured!')
}
console.log('\nHTTP Config:', JSON.stringify(config.http, null, 2))
console.log('\nDomains from helper.getDomains():', domains)
console.log('Length:', domains ? domains.length : undefined)
process.exit(0)

150
scripts/check-env.js Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Environment Configuration Checker
* Ensures .env has all required variables from .env.example
* Adds missing variables with empty values at the correct position
*/
const fs = require('fs')
const path = require('path')
const ENV_PATH = path.resolve('.env')
const EXAMPLE_PATH = path.resolve('.env.example')
const BACKUP_PATH = path.resolve('.env.backup')
console.log('48hr.email Environment Configuration Checker\n')
// Check if .env.example exists
if (!fs.existsSync(EXAMPLE_PATH)) {
console.error('ERROR: .env.example not found!')
process.exit(1)
}
// Create .env if it doesn't exist
if (!fs.existsSync(ENV_PATH)) {
console.log('INFO: .env not found, creating from .env.example...')
fs.copyFileSync(EXAMPLE_PATH, ENV_PATH)
console.log('SUCCESS: Created .env - please fill in your configuration values\n')
process.exit(0)
}
// Parse .env.example to get expected structure
const exampleContent = fs.readFileSync(EXAMPLE_PATH, 'utf8')
const exampleLines = exampleContent.split('\n')
// Parse current .env
const envContent = fs.readFileSync(ENV_PATH, 'utf8')
const envLines = envContent.split('\n')
// Extract variable names from .env
const existingVars = new Set()
envLines.forEach(line => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const varName = trimmed.split('=')[0].trim()
if (varName) existingVars.add(varName)
}
})
// Check for deprecated vars and auto-migrate
const deprecatedVars = []
const migrations = []
if (existingVars.has('USER_SESSION_SECRET')) {
if (!existingVars.has('HTTP_SESSION_SECRET')) {
// Migrate USER_SESSION_SECRET to HTTP_SESSION_SECRET
const oldLine = envLines.find(l => l.trim().startsWith('USER_SESSION_SECRET='))
if (oldLine) {
const value = oldLine.split('=').slice(1).join('=')
migrations.push({
old: 'USER_SESSION_SECRET',
new: 'HTTP_SESSION_SECRET',
value: value,
action: 'migrate'
})
}
}
deprecatedVars.push('USER_SESSION_SECRET → HTTP_SESSION_SECRET (will be removed)')
}
// Find missing variables
const missingVars = []
const newLines = []
let addedVars = 0
for (let i = 0; i < exampleLines.length; i++) {
const line = exampleLines[i]
const trimmed = line.trim()
// Preserve empty lines and section headers (comment lines)
if (!trimmed || trimmed.startsWith('#')) {
newLines.push(line)
continue
}
// Extract variable name (before the = sign)
const varName = trimmed.split('=')[0].trim()
// Skip if not a valid variable assignment
if (!varName || !trimmed.includes('=')) {
continue
}
// Check if this var exists in current .env
if (existingVars.has(varName)) {
// Find and copy the existing line from .env
const existingLine = envLines.find(l => l.trim().startsWith(varName + '='))
newLines.push(existingLine || varName + '=')
} else {
// Check if there's a migration for this variable
const migration = migrations.find(m => m.new === varName)
if (migration) {
// Use migrated value
newLines.push(`${varName}=${migration.value}`)
missingVars.push(`${varName} (migrated from ${migration.old})`)
addedVars++
} else {
// Variable is missing - add it with empty value
missingVars.push(varName)
newLines.push(`${varName}=`)
addedVars++
}
}
}
// Show results
console.log('Configuration Status:\n')
if (migrations.length > 0) {
console.log('Auto-migrations applied:')
migrations.forEach(m => console.log(` ${m.old}${m.new}`))
console.log()
}
if (deprecatedVars.length > 0) {
console.log('Deprecated variables found:')
deprecatedVars.forEach(v => console.log(` ${v}`))
console.log()
}
if (missingVars.length > 0) {
console.log(`Found ${missingVars.length} missing variable(s):`)
missingVars.forEach(v => console.log(` * ${v}`))
console.log()
// Create backup
fs.copyFileSync(ENV_PATH, BACKUP_PATH)
console.log('Created backup: .env.backup')
// Write updated .env
fs.writeFileSync(ENV_PATH, newLines.join('\n'))
console.log('Updated .env with empty placeholders')
console.log('\nPlease fill in the missing values in your .env file!\n')
} else if (deprecatedVars.length > 0) {
console.log('All variables present (but some are deprecated)\n')
} else {
console.log('All variables present and up to date!\n')
}
process.exit(0)