mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
[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:
parent
cdce7e1e46
commit
8daa0fefe9
14 changed files with 791 additions and 41 deletions
|
|
@ -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']
|
||||
|
|
|
|||
11
README.md
11
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
|
||||
|
||||
<br>
|
||||
|
||||
-----
|
||||
|
||||
## Support me
|
||||
|
||||
If you find this project useful, consider supporting its development!
|
||||
|
|
|
|||
8
app.js
8
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
222
application/smtp-service.js
Normal 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, '&')
|
||||
.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
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">×</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 %}
|
||||
|
|
|
|||
|
|
@ -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">×</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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"overrides": [{
|
||||
"overrides": [
|
||||
{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
|
|
@ -76,6 +77,7 @@
|
|||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue