From 345935f8b9904641e5f9392743ff00cd2d099601 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Mon, 5 Jan 2026 04:50:53 +0100 Subject: [PATCH] [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 --- .env.example | 6 +- .gitignore | 1 + app.js | 17 ++ application/config.js | 38 +++-- application/helper.js | 24 +-- application/mail-processing-service.js | 4 +- .../web/public/stylesheets/custom.css | 4 +- infrastructure/web/routes/account.js | 4 +- infrastructure/web/routes/auth.js | 18 ++- infrastructure/web/routes/error.js | 7 +- infrastructure/web/routes/inbox.js | 18 ++- infrastructure/web/routes/login.js | 31 ++-- infrastructure/web/routes/stats.js | 13 +- infrastructure/web/template-context.js | 57 +++++++ infrastructure/web/views/inbox.twig | 32 ++-- infrastructure/web/views/layout.twig | 9 +- infrastructure/web/views/login.twig | 38 +++-- infrastructure/web/views/mail.twig | 6 +- infrastructure/web/web.js | 28 +++- package.json | 5 +- scripts/check-domains.js | 22 +++ scripts/check-env.js | 150 ++++++++++++++++++ 22 files changed, 435 insertions(+), 97 deletions(-) create mode 100644 infrastructure/web/template-context.js create mode 100644 scripts/check-domains.js create mode 100644 scripts/check-env.js diff --git a/.env.example b/.env.example index 7d3c9a7..e36be9b 100644 --- a/.env.example +++ b/.env.example @@ -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) - diff --git a/.gitignore b/.gitignore index 3d91a02..1c86a54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.env.backup .idea .vscode .DS_Store diff --git a/app.js b/app.js index 0bbff3a..d89684a 100644 --- a/app.js +++ b/app.js @@ -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(), diff --git a/application/config.js b/application/config.js index edf2a23..01241f9 100644 --- a/application/config.js +++ b/application/config.js @@ -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, diff --git a/application/helper.js b/application/helper.js index 605ed36..f3c824b 100644 --- a/application/helper.js +++ b/application/helper.js @@ -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') diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js index 79643ed..00cc1cc 100644 --- a/application/mail-processing-service.js +++ b/application/mail-processing-service.js @@ -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( diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 1dbd4f1..905da0f 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -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; diff --git a/infrastructure/web/routes/account.js b/infrastructure/web/routes/account.js index 49adc06..5307bef 100644 --- a/infrastructure/web/routes/account.js +++ b/infrastructure/web/routes/account.js @@ -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, diff --git a/infrastructure/web/routes/auth.js b/infrastructure/web/routes/auth.js index f42ec62..2daecb1 100644 --- a/infrastructure/web/routes/auth.js +++ b/infrastructure/web/routes/auth.js @@ -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 diff --git a/infrastructure/web/routes/error.js b/infrastructure/web/routes/error.js index d272de8..7fda3f4 100644 --- a/infrastructure/web/routes/error.js +++ b/infrastructure/web/routes/error.js @@ -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 \ No newline at end of file +module.exports = router diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index e403153..faa61ce 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -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, diff --git a/infrastructure/web/routes/login.js b/infrastructure/web/routes/login.js index f96179b..0c6ce39 100644 --- a/infrastructure/web/routes/login.js +++ b/infrastructure/web/routes/login.js @@ -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` }) } diff --git a/infrastructure/web/routes/stats.js b/infrastructure/web/routes/stats.js index 9b40a01..e32dfc9 100644 --- a/infrastructure/web/routes/stats.js +++ b/infrastructure/web/routes/stats.js @@ -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, diff --git a/infrastructure/web/template-context.js b/infrastructure/web/template-context.js new file mode 100644 index 0000000..fa506a5 --- /dev/null +++ b/infrastructure/web/template-context.js @@ -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() diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig index ee3d30d..fa1d6da 100644 --- a/infrastructure/web/views/inbox.twig +++ b/infrastructure/web/views/inbox.twig @@ -4,22 +4,22 @@ + {% if alertMessage %} +
+
+ {{ alertMessage }} +
+
+ {% endif %} {% block body %}{% endblock %} {% block footer %}