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 %}
+
+
+
+
×
+
Forward All Emails
+
Enter the email address to forward all emails in this inbox to. Limited to 25 emails maximum.
+ {% if mailSummaries|length > 0 %}
+
You have {{ mailSummaries|length }} email(s) in this inbox.
+ {% endif %}
+
+
+
+
{% 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 %}
← Return to inbox
+
Forward Email
Delete Email
View Raw
{% if lockEnabled and isLocked and hasAccess %}
@@ -22,6 +23,11 @@
{% endblock %}
{% block body %}
+ {% if forwardSuccess %}
+
+ ✓ Email forwarded successfully!
+
+ {% endif %}
+
+
+
+
×
+
Forward Email
+
Enter the email address to forward this message to.
+ {% if errorMessage %}
+
{{ errorMessage }}
+ {% endif %}
+
+
+
+
+
{% endblock %}
{% block footer %}
{{ parent() }}
-
{% endblock %}
diff --git a/package-lock.json b/package-lock.json
index e562c0c..a02bf65 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 37e90e3..5953834 100644
--- a/package.json
+++ b/package.json
@@ -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"
+ ]
+ }
+ ]
}
}