[Feat]: Add email forwarding

Uses seperate SMTP credentials for forwarding. This is just the raw system, validation will be in the next commit.
This commit is contained in:
ClaraCrazy 2026-01-02 16:11:29 +01:00
parent cdce7e1e46
commit 8daa0fefe9
No known key found for this signature in database
GPG key ID: EBBC896ACB497011
14 changed files with 791 additions and 41 deletions

View file

@ -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']

View file

@ -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
<br>
-----
## Support me
If you find this project useful, consider supporting its development!

10
app.js
View file

@ -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)
}
})
})

View file

@ -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),

View file

@ -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 => {

222
application/smtp-service.js Normal file
View file

@ -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 = `
<div style="border-left: 2px solid #ccc; padding-left: 10px; margin: 10px 0;">
<p><strong>---------- Forwarded message ----------</strong><br>
<strong>From:</strong> ${this._escapeHtml(originalFrom)}<br>
<strong>Date:</strong> ${this._escapeHtml(originalDate)}<br>
<strong>Subject:</strong> ${this._escapeHtml(originalSubject)}<br>
<strong>To:</strong> ${this._escapeHtml(originalTo)}</p>
</div>
${mail.html}
`
message.html = htmlForwardedBody
}
// Add attachments if present
if (mail.attachments && mail.attachments.length > 0) {
message.attachments = mail.attachments.map(att => ({
filename: att.filename || 'attachment',
content: att.content,
contentType: att.contentType,
contentDisposition: att.contentDisposition || 'attachment'
}))
debug(`Including ${mail.attachments.length} attachment(s) in forwarded email`)
}
return message
}
/**
* Simple HTML escape for email headers
* @param {string} text
* @returns {string}
* @private
*/
_escapeHtml(text) {
if (!text) return ''
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 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

View file

@ -22,4 +22,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initRefreshCountdown === 'function' && refreshInterval) {
window.utils.initRefreshCountdown(refreshInterval);
}
});
// Initialize forward all modal
if (window.utils && typeof window.utils.initForwardAllModal === 'function') {
window.utils.initForwardAllModal();
}
});

View file

@ -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();
});
initForwardModal();
initCryptoKeysToggle();
});

View file

@ -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;
}
}
}

View file

@ -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
module.exports = router

View file

@ -11,6 +11,7 @@
<a href="#" id="lockBtn" aria-label="Protect inbox with password">Protect Inbox</a>
{% endif %}
{% endif %}
<a href="#" id="forwardAllBtn" aria-label="Forward all emails">Forward All</a>
<a href="/inbox/{{ address }}/delete-all" aria-label="Delete all emails">Wipe Inbox</a>
{% if lockEnabled and hasAccess %}
<a href="/lock/logout" aria-label="Logout">Logout</a>
@ -31,6 +32,16 @@
{% block body %}
<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>
{% endif %}
{% if errorMessage %}
<div class="unlock-error">
{{ errorMessage }}
</div>
{% endif %}
<div class="inbox-container">
<div class="inbox-header">
<h1 class="inbox-title" id="copyAddress" title="Click to copy address">{{ address }}</h1>
@ -162,4 +173,24 @@
</div>
</div>
<!-- Forward All Modal -->
<div id="forwardAllModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeForwardAll">&times;</span>
<h3>Forward All Emails</h3>
<p class="modal-description">Enter the email address to forward all emails in this inbox to. Limited to 25 emails maximum.</p>
{% if mailSummaries|length > 0 %}
<p class="modal-info">You have {{ mailSummaries|length }} email(s) in this inbox.</p>
{% endif %}
<p id="forwardAllError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/forward-all">
<fieldset>
<label for="forwardAllEmail" class="floating-label">Destination Email</label>
<input type="email" id="forwardAllEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<button type="submit" class="button-primary modal-button">Forward All</button>
</fieldset>
</form>
</div>
</div>
{% endblock %}

View file

@ -3,6 +3,7 @@
{% block header %}
<div class="action-links">
<a href="/inbox/{{ address }}" aria-label="Return to inbox">← Return to inbox</a>
<a href="#" id="forwardBtn" aria-label="Forward this email">Forward Email</a>
<a href="/inbox/{{ address }}/{{ uid }}/delete" aria-label="Delete this email">Delete Email</a>
<a href="/inbox/{{ address }}/{{ uid }}/raw" target="_blank" aria-label="View raw email">View Raw</a>
{% if lockEnabled and isLocked and hasAccess %}
@ -22,6 +23,11 @@
{% endblock %}
{% block body %}
{% if forwardSuccess %}
<div class="success-message">
✓ Email forwarded successfully!
</div>
{% endif %}
<div class="mail-container">
<div class="mail-header">
<h1 class="mail-subject">{{ mail.subject }}</h1>
@ -88,16 +94,29 @@
{% endif %}
</div>
<!-- Forward Email Modal -->
<div id="forwardModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="closeForward">&times;</span>
<h3>Forward Email</h3>
<p class="modal-description">Enter the email address to forward this message to.</p>
{% if errorMessage %}
<p class="unlock-error">{{ errorMessage }}</p>
{% endif %}
<p id="forwardError" class="unlock-error" style="display:none"></p>
<form method="POST" action="/inbox/{{ address }}/{{ uid }}/forward">
<fieldset>
<label for="forwardEmail" class="floating-label">Destination Email</label>
<input type="email" id="forwardEmail" name="destinationEmail"
placeholder="recipient@example.com" required class="modal-input">
<button type="submit" class="button-primary modal-button">Forward</button>
</fieldset>
</form>
</div>
</div>
{% endblock %}
{% block footer %}
{{ parent() }}
<script>
// Initialize crypto keys toggle
document.addEventListener('DOMContentLoaded', () => {
if (window.utils && typeof window.utils.initCryptoKeysToggle === 'function') {
window.utils.initCryptoKeysToggle();
}
});
</script>
{% endblock %}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "48hr.email",
"version": "1.7.5",
"version": "1.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "48hr.email",
"version": "1.7.5",
"version": "1.8.1",
"license": "GPL-3.0",
"dependencies": {
"async-retry": "^1.3.3",

View file

@ -67,15 +67,17 @@
}
]
},
"overrides": [{
"files": "public/javascripts/*.js",
"esnext": false,
"env": [
"browser"
],
"globals": [
"io"
]
}]
"overrides": [
{
"files": "public/javascripts/*.js",
"esnext": false,
"env": [
"browser"
],
"globals": [
"io"
]
}
]
}
}