From 8daa0fefe9e85b0fe32a209301797114752f4931 Mon Sep 17 00:00:00 2001 From: ClaraCrazy Date: Fri, 2 Jan 2026 16:11:29 +0100 Subject: [PATCH] [Feat]: Add email forwarding Uses seperate SMTP credentials for forwarding. This is just the raw system, validation will be in the next commit. --- .env.example | 9 + README.md | 11 +- app.js | 10 +- application/config.js | 10 + application/mail-processing-service.js | 65 ++++- application/smtp-service.js | 222 ++++++++++++++++++ .../web/public/javascripts/inbox-init.js | 7 +- .../web/public/javascripts/utils.js | 173 +++++++++++++- .../web/public/stylesheets/custom.css | 25 +- infrastructure/web/routes/inbox.js | 208 +++++++++++++++- infrastructure/web/views/inbox.twig | 31 +++ infrastructure/web/views/mail.twig | 35 ++- package-lock.json | 4 +- package.json | 22 +- 14 files changed, 791 insertions(+), 41 deletions(-) create mode 100644 application/smtp-service.js diff --git a/.env.example b/.env.example index 6fc97c7..cb5d293 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,15 @@ IMAP_REFRESH_INTERVAL_SECONDS=60 # Refresh interv IMAP_FETCH_CHUNK=200 # Number of UIDs per fetch chunk during initial load IMAP_CONCURRENCY=6 # Number of concurrent fetch workers during initial load +# --- SMTP CONFIGURATION (for email forwarding) --- +SMTP_ENABLED=false # Enable SMTP forwarding functionality (default: false) +SMTP_HOST="smtp.example.com" # SMTP server address (e.g., smtp.gmail.com, smtp.sendgrid.net) +SMTP_PORT=465 # SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted) +SMTP_SECURE=true # Use SSL (true for port 465, false for other ports) +SMTP_USER="noreply@48hr.email" # SMTP authentication username (also used as from address) +SMTP_PASSWORD="password" # SMTP authentication password +SMTP_FROM_NAME="48hr Email Service" # Display name for forwarded emails + # --- HTTP / WEB CONFIGURATION --- HTTP_PORT=3000 # Port HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url'] diff --git a/README.md b/README.md index 70b6bb8..149d23b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ All data is being removed 48hrs after they have reached the mail server. ## How can I set this up myself? **Prerequisites:** -- Mail server with IMAP +- Mail server with IMAP (Optionally also SMTP for email forwarding feature) - One or multiple domains dedicated to this - git & nodejs @@ -134,15 +134,6 @@ If desired, you can also move the config file somewhere else (change volume moun ----- -## TODO (PRs welcome) - -- Add user registration: - - Allow people to forward single emails, or an inbox in its current state - -
- ------ - ## Support me If you find this project useful, consider supporting its development! diff --git a/app.js b/app.js index 184c615..5e727ce 100644 --- a/app.js +++ b/app.js @@ -9,6 +9,7 @@ const { app, io, server } = require('./infrastructure/web/web') const ClientNotification = require('./infrastructure/web/client-notification') const ImapService = require('./application/imap-service') const MailProcessingService = require('./application/mail-processing-service') +const SmtpService = require('./application/smtp-service') const MailRepository = require('./domain/mail-repository') const InboxLock = require('./domain/inbox-lock') @@ -35,11 +36,16 @@ if (config.lock.enabled) { const imapService = new ImapService(config, inboxLock) debug('IMAP service initialized') + +const smtpService = new SmtpService(config) +debug('SMTP service initialized') + const mailProcessingService = new MailProcessingService( new MailRepository(), imapService, clientNotification, - config + config, + smtpService ) debug('Mail processing service initialized') @@ -113,4 +119,4 @@ server.on('error', error => { console.error('Fatal web server error', error) process.exit(1) } -}) \ No newline at end of file +}) diff --git a/application/config.js b/application/config.js index d98437e..bd65294 100644 --- a/application/config.js +++ b/application/config.js @@ -56,6 +56,16 @@ const config = { fetchConcurrency: Number(process.env.IMAP_CONCURRENCY) || 6 }, + smtp: { + enabled: parseBool(process.env.SMTP_ENABLED) || false, + host: parseValue(process.env.SMTP_HOST), + port: Number(process.env.SMTP_PORT) || 465, + secure: parseBool(process.env.SMTP_SECURE) || true, + user: parseValue(process.env.SMTP_USER), + password: parseValue(process.env.SMTP_PASSWORD), + fromName: parseValue(process.env.SMTP_FROM_NAME) || '48hr.email Forwarding' + }, + http: { port: Number(process.env.HTTP_PORT), branding: parseValue(process.env.HTTP_BRANDING), diff --git a/application/mail-processing-service.js b/application/mail-processing-service.js index 06d3069..fe4f698 100644 --- a/application/mail-processing-service.js +++ b/application/mail-processing-service.js @@ -7,12 +7,13 @@ const helper = new(Helper) class MailProcessingService extends EventEmitter { - constructor(mailRepository, imapService, clientNotification, config) { + constructor(mailRepository, imapService, clientNotification, config, smtpService = null) { super() this.mailRepository = mailRepository this.clientNotification = clientNotification this.imapService = imapService this.config = config + this.smtpService = smtpService // Cached methods: this._initCache() @@ -214,6 +215,68 @@ class MailProcessingService extends EventEmitter { } } + /** + * Forward an email to a destination address + * @param {string} address - The recipient address of the email to forward + * @param {number|string} uid - The UID of the email to forward + * @param {string} destinationEmail - The email address to forward to + * @returns {Promise<{success: boolean, error?: string, messageId?: string}>} + */ + async forwardEmail(address, uid, destinationEmail) { + // Check if SMTP service is available + if (!this.smtpService) { + debug('Forward attempt failed: SMTP service not configured') + return { + success: false, + error: 'Email forwarding is not configured. Please configure SMTP settings.' + } + } + + // Check if email exists in repository + const mailSummary = this.mailRepository.getForRecipient(address) + .find(mail => parseInt(mail.uid) === parseInt(uid)) + + if (!mailSummary) { + debug(`Forward attempt failed: Email not found (address: ${address}, uid: ${uid})`) + return { + success: false, + error: 'Email not found' + } + } + + try { + // Fetch full email content using cached method + debug(`Fetching full email for forwarding (address: ${address}, uid: ${uid})`) + const fullMail = await this.getOneFullMail(address, uid, false) + + if (!fullMail) { + debug('Forward attempt failed: Could not fetch full email') + return { + success: false, + error: 'Could not retrieve email content' + } + } + + // Forward via SMTP service + debug(`Forwarding email to ${destinationEmail}`) + const result = await this.smtpService.forwardMail(fullMail, destinationEmail) + + if (result.success) { + debug(`Email forwarded successfully. MessageId: ${result.messageId}`) + } else { + debug(`Email forwarding failed: ${result.error}`) + } + + return result + } catch (error) { + debug('Error forwarding email:', error.message) + return { + success: false, + error: `Failed to forward email: ${error.message}` + } + } + } + _saveToFile(mails, filename) { const fs = require('fs') fs.writeFile(filename, JSON.stringify(mails), err => { diff --git a/application/smtp-service.js b/application/smtp-service.js new file mode 100644 index 0000000..9a5ef8a --- /dev/null +++ b/application/smtp-service.js @@ -0,0 +1,222 @@ +const nodemailer = require('nodemailer') +const debug = require('debug')('48hr-email:smtp-service') + +/** + * SMTP Service for forwarding emails + * Uses nodemailer to send forwarded emails via configured SMTP server + */ +class SmtpService { + constructor(config) { + this.config = config + this.transporter = null + + // Only initialize transporter if SMTP is configured + if (this._isConfigured()) { + this._initializeTransporter() + } else { + debug('SMTP not configured - forwarding functionality will be unavailable') + } + } + + /** + * Check if SMTP is properly configured + * @returns {boolean} + */ + _isConfigured() { + return !!( + this.config.smtp.enabled && + this.config.smtp.host && + this.config.smtp.user && + this.config.smtp.password + ) + } + + /** + * Initialize the nodemailer transporter + * @private + */ + _initializeTransporter() { + try { + this.transporter = nodemailer.createTransport({ + host: this.config.smtp.host, + port: this.config.smtp.port, + secure: this.config.smtp.secure, + auth: { + user: this.config.smtp.user, + pass: this.config.smtp.password + }, + tls: { + // Allow self-signed certificates and skip verification + // This is useful for development or internal SMTP servers + rejectUnauthorized: false + } + }) + + debug(`SMTP transporter initialized: ${this.config.smtp.host}:${this.config.smtp.port}`) + } catch (error) { + debug('Failed to initialize SMTP transporter:', error.message) + throw new Error(`SMTP initialization failed: ${error.message}`) + } + } + + /** + * Forward an email to a destination address + * @param {Object} mail - Parsed email object from mailparser + * @param {string} destinationEmail - Email address to forward to + * @returns {Promise<{success: boolean, error?: string, messageId?: string}>} + */ + async forwardMail(mail, destinationEmail) { + if (!this.transporter) { + return { + success: false, + error: 'SMTP is not configured. Please configure SMTP settings to enable forwarding.' + } + } + + if (!mail) { + return { + success: false, + error: 'Email not found' + } + } + + try { + debug(`Forwarding email (Subject: "${mail.subject}") to ${destinationEmail}`) + + const forwardMessage = this._buildForwardMessage(mail, destinationEmail) + + const info = await this.transporter.sendMail(forwardMessage) + + debug(`Email forwarded successfully. MessageId: ${info.messageId}`) + + return { + success: true, + messageId: info.messageId + } + } catch (error) { + debug('Failed to forward email:', error.message) + return { + success: false, + error: `Failed to send email: ${error.message}` + } + } + } + + /** + * Build the forward message structure + * @param {Object} mail - Parsed email object + * @param {string} destinationEmail - Destination address + * @returns {Object} - Nodemailer message object + * @private + */ + _buildForwardMessage(mail, destinationEmail) { + // Extract original sender info + const originalFrom = (mail.from && mail.from.text) || 'Unknown Sender' + const originalTo = (mail.to && mail.to.text) || 'Unknown Recipient' + const originalDate = mail.date ? new Date(mail.date).toLocaleString() : 'Unknown Date' + const originalSubject = mail.subject || '(no subject)' + + // Build forwarded message body + let forwardedBody = ` +---------- Forwarded message ---------- +From: ${originalFrom} +Date: ${originalDate} +Subject: ${originalSubject} +To: ${originalTo} + + +` + + // Add original text body if available + if (mail.text) { + forwardedBody += mail.text + } else if (mail.html) { + // If only HTML is available, mention it + forwardedBody += '[This email contains HTML content. See attachment or HTML version below.]\n\n' + } + + // Build the message object + const message = { + from: { + name: this.config.smtp.fromName, + address: this.config.smtp.user + }, + to: destinationEmail, + subject: `Fwd: ${originalSubject}`, + text: forwardedBody, + replyTo: originalFrom + } + + // Add HTML body if available + if (mail.html) { + const htmlForwardedBody = ` +
+

---------- Forwarded message ----------
+ From: ${this._escapeHtml(originalFrom)}
+ Date: ${this._escapeHtml(originalDate)}
+ Subject: ${this._escapeHtml(originalSubject)}
+ To: ${this._escapeHtml(originalTo)}

+
+${mail.html} +` + message.html = htmlForwardedBody + } + + // Add attachments if present + if (mail.attachments && mail.attachments.length > 0) { + message.attachments = mail.attachments.map(att => ({ + filename: att.filename || 'attachment', + content: att.content, + contentType: att.contentType, + contentDisposition: att.contentDisposition || 'attachment' + })) + + debug(`Including ${mail.attachments.length} attachment(s) in forwarded email`) + } + + return message + } + + /** + * Simple HTML escape for email headers + * @param {string} text + * @returns {string} + * @private + */ + _escapeHtml(text) { + if (!text) return '' + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + /** + * Verify SMTP connection + * @returns {Promise<{success: boolean, error?: string}>} + */ + async verifyConnection() { + if (!this.transporter) { + return { + success: false, + error: 'SMTP is not configured' + } + } + + try { + await this.transporter.verify() + debug('SMTP connection verified successfully') + return { success: true } + } catch (error) { + debug('SMTP connection verification failed:', error.message) + return { + success: false, + error: error.message + } + } + } +} + +module.exports = SmtpService diff --git a/infrastructure/web/public/javascripts/inbox-init.js b/infrastructure/web/public/javascripts/inbox-init.js index c1e514d..c76bf0e 100644 --- a/infrastructure/web/public/javascripts/inbox-init.js +++ b/infrastructure/web/public/javascripts/inbox-init.js @@ -22,4 +22,9 @@ document.addEventListener('DOMContentLoaded', () => { if (window.utils && typeof window.utils.initRefreshCountdown === 'function' && refreshInterval) { window.utils.initRefreshCountdown(refreshInterval); } -}); \ No newline at end of file + + // Initialize forward all modal + if (window.utils && typeof window.utils.initForwardAllModal === 'function') { + window.utils.initForwardAllModal(); + } +}); diff --git a/infrastructure/web/public/javascripts/utils.js b/infrastructure/web/public/javascripts/utils.js index 0fff77b..f4283bf 100644 --- a/infrastructure/web/public/javascripts/utils.js +++ b/infrastructure/web/public/javascripts/utils.js @@ -361,6 +361,173 @@ document.addEventListener('DOMContentLoaded', () => { } } + function initForwardModal() { + const forwardModal = document.getElementById('forwardModal'); + const forwardBtn = document.getElementById('forwardBtn'); + const closeForward = document.getElementById('closeForward'); + const forwardForm = document.querySelector('#forwardModal form'); + const forwardEmail = document.getElementById('forwardEmail'); + const forwardError = document.getElementById('forwardError'); + + if (!forwardModal || !forwardBtn) return; + + const openModal = (m) => { if (m) m.style.display = 'block'; }; + const closeModal = (m) => { if (m) m.style.display = 'none'; }; + + forwardBtn.onclick = function(e) { + e.preventDefault(); + openModal(forwardModal); + if (forwardEmail) forwardEmail.focus(); + }; + + if (closeForward) { + closeForward.onclick = function() { + closeModal(forwardModal); + if (forwardError) forwardError.style.display = 'none'; + }; + } + + if (forwardForm) { + forwardForm.addEventListener('submit', function(e) { + const email = forwardEmail ? forwardEmail.value.trim() : ''; + + // Basic client-side validation + if (!email) { + e.preventDefault(); + if (forwardError) { + forwardError.textContent = 'Please enter an email address.'; + forwardError.style.display = 'block'; + } + return; + } + + // Simple email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + e.preventDefault(); + if (forwardError) { + forwardError.textContent = 'Please enter a valid email address.'; + forwardError.style.display = 'block'; + } + return; + } + + if (forwardError) forwardError.style.display = 'none'; + }); + } + + window.addEventListener('click', function(e) { + if (e.target === forwardModal) { + closeModal(forwardModal); + if (forwardError) forwardError.style.display = 'none'; + } + }); + + // Check if there's a server error and re-open modal + const serverError = forwardModal.querySelector('.unlock-error'); + if (serverError && serverError.textContent.trim() && !serverError.id) { + openModal(forwardModal); + if (forwardEmail) forwardEmail.focus(); + } + + // Check for success message in URL + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('forwarded') === 'true') { + // Remove the query param from URL without reload + const newUrl = window.location.pathname; + window.history.replaceState({}, '', newUrl); + } + } + + function initForwardAllModal() { + const forwardAllModal = document.getElementById('forwardAllModal'); + const forwardAllBtn = document.getElementById('forwardAllBtn'); + const closeForwardAll = document.getElementById('closeForwardAll'); + const forwardAllForm = document.querySelector('#forwardAllModal form'); + const forwardAllEmail = document.getElementById('forwardAllEmail'); + const forwardAllError = document.getElementById('forwardAllError'); + + if (!forwardAllModal || !forwardAllBtn) return; + + const openModal = (m) => { if (m) m.style.display = 'block'; }; + const closeModal = (m) => { if (m) m.style.display = 'none'; }; + + forwardAllBtn.onclick = function(e) { + e.preventDefault(); + openModal(forwardAllModal); + if (forwardAllEmail) forwardAllEmail.focus(); + }; + + if (closeForwardAll) { + closeForwardAll.onclick = function() { + closeModal(forwardAllModal); + if (forwardAllError) forwardAllError.style.display = 'none'; + }; + } + + if (forwardAllForm) { + forwardAllForm.addEventListener('submit', function(e) { + const email = forwardAllEmail ? forwardAllEmail.value.trim() : ''; + + // Basic client-side validation + if (!email) { + e.preventDefault(); + if (forwardAllError) { + forwardAllError.textContent = 'Please enter an email address.'; + forwardAllError.style.display = 'block'; + } + return; + } + + // Simple email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + e.preventDefault(); + if (forwardAllError) { + forwardAllError.textContent = 'Please enter a valid email address.'; + forwardAllError.style.display = 'block'; + } + return; + } + + if (forwardAllError) forwardAllError.style.display = 'none'; + }); + } + + window.addEventListener('click', function(e) { + if (e.target === forwardAllModal) { + closeModal(forwardAllModal); + if (forwardAllError) forwardAllError.style.display = 'none'; + } + }); + + // Check for success message in URL + const urlParams = new URLSearchParams(window.location.search); + + // Check if we just came from a forward all attempt with an error + // Look for error message in the page that might be related to forwarding + const pageErrorDiv = document.querySelector('.unlock-error'); + if (pageErrorDiv && !urlParams.get('forwardedAll')) { + const errorText = pageErrorDiv.textContent.trim(); + // Check if error is related to forwarding + if (errorText.includes('forward') || errorText.includes('email') || errorText.includes('25')) { + openModal(forwardAllModal); + if (forwardAllEmail) forwardAllEmail.focus(); + // Move error into modal + if (forwardAllError) { + forwardAllError.textContent = errorText; + forwardAllError.style.display = 'block'; + } + } + } + + if (urlParams.get('forwardedAll')) { + // Remove the query param from URL without reload + const newUrl = window.location.pathname; + window.history.replaceState({}, '', newUrl); + } + } + function initRefreshCountdown(refreshInterval) { const refreshTimer = document.getElementById('refreshTimer'); if (!refreshTimer || !refreshInterval) return; @@ -377,7 +544,7 @@ document.addEventListener('DOMContentLoaded', () => { } // Expose utilities and run them - window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle }; + window.utils = { formatEmailDates, formatMailDate, initLockModals, initCopyAddress, initExpiryTimers, initQrModal, initHamburgerMenu, initThemeToggle, initRefreshCountdown, initCryptoKeysToggle, initForwardModal, initForwardAllModal }; formatEmailDates(); formatMailDate(); initLockModals(); @@ -385,4 +552,6 @@ document.addEventListener('DOMContentLoaded', () => { initQrModal(); initHamburgerMenu(); initThemeToggle(); -}); \ No newline at end of file + initForwardModal(); + initCryptoKeysToggle(); +}); diff --git a/infrastructure/web/public/stylesheets/custom.css b/infrastructure/web/public/stylesheets/custom.css index 823c75e..4da3645 100644 --- a/infrastructure/web/public/stylesheets/custom.css +++ b/infrastructure/web/public/stylesheets/custom.css @@ -931,6 +931,16 @@ label { font-size: 1.4rem; } +.modal-info { + color: var(--color-text-primary); + margin-bottom: 1rem; + padding: 0.8rem; + background: var(--overlay-purple-08); + border-left: 3px solid var(--color-accent-purple); + border-radius: 4px; + font-size: 1.3rem; +} + .close { float: right; font-size: 2.8rem; @@ -993,6 +1003,19 @@ label { } +/* Success Messages */ + +.success-message { + color: #2ecc71; + margin-bottom: 1.5rem; + padding: 1rem; + background: rgba(46, 204, 113, 0.1); + border-left: 3px solid #2ecc71; + border-radius: 4px; + font-weight: 500; +} + + /* Remove Lock Button Styles */ .modal-button-danger { @@ -1289,4 +1312,4 @@ body.light-mode .theme-icon-light { .qr-icon-btn { display: none; } -} \ No newline at end of file +} diff --git a/infrastructure/web/routes/inbox.js b/infrastructure/web/routes/inbox.js index ec1be3a..d747729 100644 --- a/infrastructure/web/routes/inbox.js +++ b/infrastructure/web/routes/inbox.js @@ -1,6 +1,6 @@ const express = require('express') const router = new express.Router() -const { param } = require('express-validator') +const { param, body, validationResult } = require('express-validator') const debug = require('debug')('48hr-email:routes') const config = require('../../../application/config') @@ -40,6 +40,63 @@ const validateDomain = (req, res, next) => { next() } +// Simple in-memory rate limiter for forwarding (5 requests per 15 minutes per IP) +const forwardRateLimitStore = new Map() +const forwardLimiter = (req, res, next) => { + const ip = req.ip || req.connection.remoteAddress + const now = Date.now() + const windowMs = 15 * 60 * 1000 // 15 minutes + const maxRequests = 5 + + // Clean up old entries + for (const [key, data] of forwardRateLimitStore.entries()) { + if (now - data.resetTime > windowMs) { + forwardRateLimitStore.delete(key) + } + } + + // Get or create entry for this IP + let ipData = forwardRateLimitStore.get(ip) + if (!ipData || now - ipData.resetTime > windowMs) { + ipData = { count: 0, resetTime: now } + forwardRateLimitStore.set(ip, ipData) + } + + // Check if limit exceeded + if (ipData.count >= maxRequests) { + debug(`Rate limit exceeded for IP ${ip}`) + req.session.errorMessage = 'Too many forward requests. Please try again after 15 minutes.' + return res.redirect(`/inbox/${req.params.address}`) + } + + // Increment counter + ipData.count++ + next() +} + +// Email validation middleware for forwarding +const validateForwardRequest = [ + sanitizeAddress, + body('destinationEmail') + .trim() + .isEmail() + .withMessage('Invalid email address format') + .normalizeEmail() + .custom((value) => { + // Prevent forwarding to temporary email addresses + const domain = value.split('@')[1] + if (!domain) { + throw new Error('Invalid email address') + } + + const tempDomains = config.email.domains.map(d => d.toLowerCase()) + if (tempDomains.includes(domain.toLowerCase())) { + throw new Error('Cannot forward to temporary email addresses') + } + return true + }) +] + router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLockAccess, async(req, res, next) => { try { const mailProcessingService = req.app.get('mailProcessingService') @@ -58,11 +115,16 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo // Pull any lock error from session and clear it after reading const lockError = req.session ? req.session.lockError : undefined const unlockErrorSession = req.session ? req.session.unlockError : undefined + const errorMessage = req.session ? req.session.errorMessage : undefined if (req.session) { delete req.session.lockError delete req.session.unlockError + delete req.session.errorMessage } + // Check for forward all success flag + const forwardAllSuccess = req.query.forwardedAll ? parseInt(req.query.forwardedAll) : null + res.render('inbox', { title: `${config.http.branding[0]} | ` + req.params.address, purgeTime: purgeTime, @@ -80,7 +142,9 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo redirectTo: req.originalUrl, expiryTime: config.email.purgeTime.time, expiryUnit: config.email.purgeTime.unit, - refreshInterval: config.imap.refreshIntervalSeconds + refreshInterval: config.imap.refreshIntervalSeconds, + errorMessage: errorMessage, + forwardAllSuccess: forwardAllSuccess }) } catch (error) { debug(`Error loading inbox for ${req.params.address}:`, error.message) @@ -122,6 +186,15 @@ router.get( const isLocked = inboxLock && inboxLock.isLocked(req.params.address) const hasAccess = req.session && req.session.lockedInbox === req.params.address + // Pull error message from session and clear it + const errorMessage = req.session ? req.session.errorMessage : undefined + if (req.session) { + delete req.session.errorMessage + } + + // Check for forward success flag + const forwardSuccess = req.query.forwarded === 'true' + debug(`Rendering email view for UID ${req.params.uid}`) res.render('mail', { title: mail.subject + " | " + req.params.address, @@ -135,7 +208,9 @@ router.get( branding: config.http.branding, lockEnabled: config.lock.enabled, isLocked: isLocked, - hasAccess: hasAccess + hasAccess: hasAccess, + errorMessage: errorMessage, + forwardSuccess: forwardSuccess }) } else { debug(`Email ${req.params.uid} not found for ${req.params.address}`) @@ -331,6 +406,131 @@ router.get( } ) +// POST route for forwarding a single email +router.post( + '^/:address/:uid/forward', + forwardLimiter, + validateDomain, + checkLockAccess, + validateForwardRequest, + async(req, res, next) => { + try { + const errors = validationResult(req) + if (!errors.isEmpty()) { + const firstError = errors.array()[0].msg + debug(`Forward validation failed for ${req.params.address}: ${firstError}`) + req.session.errorMessage = firstError + return res.redirect(`/inbox/${req.params.address}/${req.params.uid}`) + } + + const mailProcessingService = req.app.get('mailProcessingService') + const { destinationEmail } = req.body + const uid = parseInt(req.params.uid, 10) + + debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail}`) + + const result = await mailProcessingService.forwardEmail( + req.params.address, + uid, + destinationEmail + ) + + if (result.success) { + debug(`Email ${uid} forwarded successfully to ${destinationEmail}`) + return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`) + } else { + debug(`Failed to forward email ${uid}: ${result.error}`) + req.session.errorMessage = result.error + return res.redirect(`/inbox/${req.params.address}/${uid}`) + } + } catch (error) { + debug(`Error forwarding email ${req.params.uid}: ${error.message}`) + console.error('Error while forwarding email', error) + req.session.errorMessage = 'An unexpected error occurred while forwarding the email.' + res.redirect(`/inbox/${req.params.address}/${req.params.uid}`) + } + } +) + +// POST route for forwarding all emails in an inbox +router.post( + '^/:address/forward-all', + forwardLimiter, + validateDomain, + checkLockAccess, + validateForwardRequest, + async(req, res, next) => { + try { + const validationErrors = validationResult(req) + if (!validationErrors.isEmpty()) { + const firstError = validationErrors.array()[0].msg + debug(`Forward all validation failed for ${req.params.address}: ${firstError}`) + req.session.errorMessage = firstError + return res.redirect(`/inbox/${req.params.address}`) + } + + const mailProcessingService = req.app.get('mailProcessingService') + const { destinationEmail } = req.body + + debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail}`) + + const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address) + + // Limit bulk forwarding to 25 emails + const MAX_FORWARD_ALL = 25 + if (mailSummaries.length > MAX_FORWARD_ALL) { + debug(`Forward all blocked: ${mailSummaries.length} emails exceeds limit of ${MAX_FORWARD_ALL}`) + req.session.errorMessage = `Cannot forward more than ${MAX_FORWARD_ALL} emails at once. You have ${mailSummaries.length} emails.` + return res.redirect(`/inbox/${req.params.address}`) + } + + if (mailSummaries.length === 0) { + debug(`No emails to forward for ${req.params.address}`) + req.session.errorMessage = 'No emails to forward.' + return res.redirect(`/inbox/${req.params.address}`) + } + + let successCount = 0 + let failCount = 0 + const failMessages = [] + + for (const mail of mailSummaries) { + const result = await mailProcessingService.forwardEmail( + req.params.address, + mail.uid, + destinationEmail + ) + + if (result.success) { + successCount++ + debug(`Successfully forwarded email UID ${mail.uid}`) + } else { + failCount++ + debug(`Failed to forward email UID ${mail.uid}: ${result.error}`) + failMessages.push(`UID ${mail.uid}: ${result.error}`) + } + } + + debug(`Forward all complete: ${successCount} succeeded, ${failCount} failed`) + + if (successCount > 0 && failCount === 0) { + return res.redirect(`/inbox/${req.params.address}?forwardedAll=${successCount}`) + } else if (successCount > 0 && failCount > 0) { + req.session.errorMessage = `Forwarded ${successCount} email(s), but ${failCount} failed.` + return res.redirect(`/inbox/${req.params.address}`) + } else { + req.session.errorMessage = `Failed to forward emails: ${failMessages[0] || 'Unknown error'}` + return res.redirect(`/inbox/${req.params.address}`) + } + } catch (error) { + debug(`Error forwarding all emails: ${error.message}`) + console.error('Error while forwarding all emails', error) + req.session.errorMessage = 'An unexpected error occurred while forwarding emails.' + res.redirect(`/inbox/${req.params.address}`) + } + } +) + // Final catch-all for invalid UIDs (non-numeric or unmatched patterns) router.get( '^/:address/:uid', @@ -343,4 +543,4 @@ router.get( ) -module.exports = router \ No newline at end of file +module.exports = router diff --git a/infrastructure/web/views/inbox.twig b/infrastructure/web/views/inbox.twig index 3e0967c..5b5c1c3 100644 --- a/infrastructure/web/views/inbox.twig +++ b/infrastructure/web/views/inbox.twig @@ -11,6 +11,7 @@ Protect Inbox {% endif %} {% endif %} + Forward All Wipe Inbox {% if lockEnabled and hasAccess %} Logout @@ -31,6 +32,16 @@ {% block body %} + {% if forwardAllSuccess %} +
+ ✓ Successfully forwarded {{ forwardAllSuccess }} email(s)! +
+ {% endif %} + {% if errorMessage %} +
+ {{ errorMessage }} +
+ {% endif %}

{{ address }}

@@ -162,4 +173,24 @@
+ + {% endblock %} diff --git a/infrastructure/web/views/mail.twig b/infrastructure/web/views/mail.twig index 313db03..4f2b0dc 100644 --- a/infrastructure/web/views/mail.twig +++ b/infrastructure/web/views/mail.twig @@ -3,6 +3,7 @@ {% block header %}