mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-09 11:19:36 +01:00
Uses seperate SMTP credentials for forwarding. This is just the raw system, validation will be in the next commit.
546 lines
22 KiB
JavaScript
546 lines
22 KiB
JavaScript
const express = require('express')
|
|
const router = new express.Router()
|
|
const { param, body, validationResult } = require('express-validator')
|
|
const debug = require('debug')('48hr-email:routes')
|
|
|
|
const config = require('../../../application/config')
|
|
const Helper = require('../../../application/helper')
|
|
const CryptoDetector = require('../../../application/crypto-detector')
|
|
const helper = new(Helper)
|
|
const cryptoDetector = new CryptoDetector()
|
|
const { checkLockAccess } = require('../middleware/lock')
|
|
|
|
const purgeTime = helper.purgeTimeElemetBuilder()
|
|
|
|
|
|
const sanitizeAddress = param('address').customSanitizer(
|
|
(value, { req }) => {
|
|
return req.params.address
|
|
.replace(/[^A-Za-z0-9_.+@-]/g, '') // Remove special characters
|
|
.toLowerCase()
|
|
}
|
|
)
|
|
|
|
// Middleware to validate domain is in allowed list
|
|
const validateDomain = (req, res, next) => {
|
|
const address = req.params.address
|
|
const domain = address.split('@')[1]
|
|
|
|
if (!domain) {
|
|
req.session.errorMessage = 'Invalid email address format.'
|
|
return res.redirect(`/error/${address}/400`)
|
|
}
|
|
|
|
const allowedDomains = config.email.domains.map(d => d.toLowerCase())
|
|
if (!allowedDomains.includes(domain.toLowerCase())) {
|
|
req.session.errorMessage = `Domain '${domain}' is not supported by this service.`
|
|
return res.redirect(`/error/${address}/403`)
|
|
}
|
|
|
|
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')
|
|
if (!mailProcessingService) {
|
|
throw new Error('Mail processing service not available')
|
|
}
|
|
debug(`Inbox request for ${req.params.address}`)
|
|
const inboxLock = req.app.get('inboxLock')
|
|
const count = await mailProcessingService.getCount()
|
|
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
debug(`Rendering inbox with ${count} total mails`)
|
|
const isLocked = inboxLock && inboxLock.isLocked(req.params.address)
|
|
const hasAccess = req.session && req.session.lockedInbox === req.params.address
|
|
|
|
// 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,
|
|
address: req.params.address,
|
|
count: count,
|
|
totalcount: totalcount,
|
|
mailSummaries: mailProcessingService.getMailSummaries(req.params.address),
|
|
branding: config.http.branding,
|
|
lockEnabled: config.lock.enabled,
|
|
isLocked: isLocked,
|
|
hasAccess: hasAccess,
|
|
unlockError: unlockErrorSession,
|
|
locktimer: config.lock.releaseHours,
|
|
error: lockError,
|
|
redirectTo: req.originalUrl,
|
|
expiryTime: config.email.purgeTime.time,
|
|
expiryUnit: config.email.purgeTime.unit,
|
|
refreshInterval: config.imap.refreshIntervalSeconds,
|
|
errorMessage: errorMessage,
|
|
forwardAllSuccess: forwardAllSuccess
|
|
})
|
|
} catch (error) {
|
|
debug(`Error loading inbox for ${req.params.address}:`, error.message)
|
|
console.error('Error while loading inbox', error)
|
|
next(error)
|
|
}
|
|
})
|
|
|
|
router.get(
|
|
'^/:address/:uid([0-9]+)',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
checkLockAccess,
|
|
async(req, res, next) => {
|
|
try {
|
|
const mailProcessingService = req.app.get('mailProcessingService')
|
|
debug(`Viewing email ${req.params.uid} for ${req.params.address}`)
|
|
const count = await mailProcessingService.getCount()
|
|
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
const mail = await mailProcessingService.getOneFullMail(
|
|
req.params.address,
|
|
req.params.uid
|
|
)
|
|
if (mail) {
|
|
// Set a default subject if none is present
|
|
if (!mail.subject) {
|
|
mail.subject = 'No Subject'
|
|
}
|
|
|
|
// Emails are immutable, cache if found
|
|
res.set('Cache-Control', 'private, max-age=600')
|
|
|
|
// Detect cryptographic keys in attachments
|
|
const cryptoAttachments = cryptoDetector.detectCryptoAttachments(mail.attachments)
|
|
debug(`Found ${cryptoAttachments.length} cryptographic attachments`)
|
|
|
|
const inboxLock = req.app.get('inboxLock')
|
|
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,
|
|
purgeTime: purgeTime,
|
|
address: req.params.address,
|
|
count: count,
|
|
totalcount: totalcount,
|
|
mail,
|
|
cryptoAttachments: cryptoAttachments,
|
|
uid: req.params.uid,
|
|
branding: config.http.branding,
|
|
lockEnabled: config.lock.enabled,
|
|
isLocked: isLocked,
|
|
hasAccess: hasAccess,
|
|
errorMessage: errorMessage,
|
|
forwardSuccess: forwardSuccess
|
|
})
|
|
} else {
|
|
debug(`Email ${req.params.uid} not found for ${req.params.address}`)
|
|
req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!'
|
|
res.redirect(`/error/${req.params.address}/404`)
|
|
}
|
|
} catch (error) {
|
|
debug(`Error fetching email ${req.params.uid} for ${req.params.address}:`, error.message)
|
|
console.error('Error while fetching email', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Catch-all for invalid UIDs (non-numeric)
|
|
router.get(
|
|
'^/:address/delete-all',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
checkLockAccess,
|
|
async(req, res, next) => {
|
|
try {
|
|
const mailProcessingService = req.app.get('mailProcessingService')
|
|
debug(`Deleting all emails for ${req.params.address}`)
|
|
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
|
|
// Create a copy of the array to avoid modification during iteration
|
|
const summariesToDelete = [...mailSummaries]
|
|
|
|
let deletedCount = 0
|
|
for (const mail of summariesToDelete) {
|
|
await mailProcessingService.deleteSpecificEmail(req.params.address, mail.uid)
|
|
deletedCount++
|
|
debug(`Successfully deleted UID ${mail.uid}`)
|
|
}
|
|
|
|
debug(`Deleted all ${deletedCount} emails for ${req.params.address}`)
|
|
res.redirect(`/inbox/${req.params.address}`)
|
|
} catch (error) {
|
|
debug(`Error deleting all emails for ${req.params.address}:`, error.message)
|
|
console.error('Error while deleting email', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
|
|
router.get(
|
|
'^/:address/:uid/delete',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
checkLockAccess,
|
|
async(req, res, next) => {
|
|
try {
|
|
const mailProcessingService = req.app.get('mailProcessingService')
|
|
mailProcessingService.deleteSpecificEmail(req.params.address, req.params.uid)
|
|
res.redirect(`/inbox/${req.params.address}`)
|
|
} catch (error) {
|
|
debug(`Error deleting email ${req.params.uid} for ${req.params.address}:`, error.message)
|
|
console.error('Error while deleting email', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
router.get(
|
|
'^/:address/:uid/:checksum([a-f0-9]+)',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
checkLockAccess,
|
|
async(req, res, next) => {
|
|
try {
|
|
const mailProcessingService = req.app.get('mailProcessingService')
|
|
debug(`Fetching attachment ${req.params.checksum} for email ${req.params.uid} (${req.params.address})`)
|
|
const uid = parseInt(req.params.uid, 10)
|
|
const count = await mailProcessingService.getCount()
|
|
|
|
// Validate UID is a valid integer
|
|
if (isNaN(uid) || uid <= 0) {
|
|
debug(`Invalid UID provided: ${req.params.uid}`)
|
|
req.session.errorMessage = 'Invalid/Malformed UID provided.'
|
|
return res.redirect(`/error/${req.params.address}/400`)
|
|
}
|
|
|
|
const mail = await mailProcessingService.getOneFullMail(
|
|
req.params.address,
|
|
uid
|
|
)
|
|
|
|
if (!mail || !mail.attachments) {
|
|
debug(`Email ${uid} or attachments not found for ${req.params.address}`)
|
|
req.session.errorMessage = 'This email could not be found. It either does not exist or has been deleted from our servers!'
|
|
return res.redirect(`/error/${req.params.address}/404`)
|
|
}
|
|
|
|
var index = mail.attachments.findIndex(attachment => attachment.checksum === req.params.checksum);
|
|
const attachment = mail.attachments[index];
|
|
|
|
if (attachment) {
|
|
try {
|
|
debug(`Serving attachment: ${attachment.filename}`)
|
|
res.set('Content-Disposition', `attachment; filename=${attachment.filename}`);
|
|
res.set('Content-Type', attachment.contentType);
|
|
res.send(attachment.content);
|
|
return;
|
|
} catch (error) {
|
|
debug(`Error serving attachment: ${error.message}`)
|
|
console.error('Error while fetching attachment', error);
|
|
next(error);
|
|
return;
|
|
}
|
|
} else {
|
|
debug(`Attachment ${req.params.checksum} not found in email ${uid}`)
|
|
req.session.errorMessage = 'This attachment could not be found. It either does not exist or has been deleted from our servers!'
|
|
return res.redirect(`/error/${req.params.address}/404`)
|
|
}
|
|
} catch (error) {
|
|
debug(`Error fetching attachment: ${error.message}`)
|
|
console.error('Error while fetching attachment', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
|
|
router.get(
|
|
'^/:address/:uid/raw',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
checkLockAccess,
|
|
async(req, res, next) => {
|
|
try {
|
|
const mailProcessingService = req.app.get('mailProcessingService')
|
|
debug(`Fetching raw email ${req.params.uid} for ${req.params.address}`)
|
|
const uid = parseInt(req.params.uid, 10)
|
|
const count = await mailProcessingService.getCount()
|
|
const largestUid = await req.app.locals.imapService.getLargestUid()
|
|
const totalcount = helper.countElementBuilder(count, largestUid)
|
|
|
|
// Validate UID is a valid integer
|
|
if (isNaN(uid) || uid <= 0) {
|
|
debug(`Invalid UID provided for raw view: ${req.params.uid}`)
|
|
req.session.errorMessage = 'Invalid/Malformed UID provided.'
|
|
return res.redirect(`/error/${req.params.address}/400`)
|
|
}
|
|
|
|
mail = await mailProcessingService.getOneFullMail(
|
|
req.params.address,
|
|
uid,
|
|
true
|
|
)
|
|
if (mail) {
|
|
const decodeQuotedPrintable = (input) => {
|
|
if (!input) return '';
|
|
// Remove soft line breaks
|
|
let cleaned = input.replace(/=\r?\n/g, '');
|
|
// Decode =XX hex escapes
|
|
cleaned = cleaned.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => {
|
|
try {
|
|
return String.fromCharCode(parseInt(hex, 16));
|
|
} catch {
|
|
return '=' + hex;
|
|
}
|
|
});
|
|
return cleaned;
|
|
};
|
|
|
|
const decodedMail = decodeQuotedPrintable(mail);
|
|
|
|
// Keep raw content but add literal newlines after <br> tags for readability
|
|
const rawMail = mail.replace(/<br\s*\/?\s*>/gi, '<br>\n');
|
|
|
|
// Emails are immutable, cache if found
|
|
res.set('Cache-Control', 'private, max-age=600')
|
|
debug(`Rendering raw email view for UID ${req.params.uid}`)
|
|
res.render('raw', {
|
|
title: req.params.uid + " | raw | " + req.params.address,
|
|
mail: rawMail,
|
|
decoded: decodedMail,
|
|
totalcount: totalcount
|
|
})
|
|
} else {
|
|
debug(`Raw email ${uid} not found for ${req.params.address}`)
|
|
req.session.errorMessage = 'This mail could not be found. It either does not exist or has been deleted from our servers!'
|
|
res.redirect(`/error/${req.params.address}/404`)
|
|
}
|
|
} catch (error) {
|
|
debug(`Error fetching raw email ${req.params.uid}: ${error.message}`)
|
|
console.error('Error while fetching raw email', error)
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// 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',
|
|
sanitizeAddress,
|
|
validateDomain,
|
|
async(req, res) => {
|
|
req.session.errorMessage = 'Invalid/Malformed UID provided.'
|
|
res.redirect(`/error/${req.params.address}/400`)
|
|
}
|
|
)
|
|
|
|
|
|
module.exports = router
|