mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 18:59:36 +01:00
[Feat]: Add email validation function
Currently only used for forwarding
This commit is contained in:
parent
8daa0fefe9
commit
2a08aa14a8
15 changed files with 793 additions and 43 deletions
|
|
@ -34,6 +34,7 @@ SMTP_FROM_NAME="48hr Email Service" # Display name f
|
|||
|
||||
# --- HTTP / WEB CONFIGURATION ---
|
||||
HTTP_PORT=3000 # Port
|
||||
HTTP_BASE_URL="http://localhost:3000" # Base URL for verification links (e.g., https://48hr.email)
|
||||
HTTP_BRANDING=["48hr.email","CrazyCo","https://crazyco.xyz"] # ['service_title', 'company_name', 'company_url']
|
||||
HTTP_DISPLAY_SORT=2 # Domain display sorting:
|
||||
# 0 = no change,
|
||||
|
|
|
|||
8
app.js
8
app.js
|
|
@ -12,6 +12,7 @@ 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')
|
||||
const VerificationStore = require('./domain/verification-store')
|
||||
|
||||
const clientNotification = new ClientNotification()
|
||||
debug('Client notification service initialized')
|
||||
|
|
@ -40,12 +41,17 @@ debug('IMAP service initialized')
|
|||
const smtpService = new SmtpService(config)
|
||||
debug('SMTP service initialized')
|
||||
|
||||
const verificationStore = new VerificationStore()
|
||||
debug('Verification store initialized')
|
||||
app.set('verificationStore', verificationStore)
|
||||
|
||||
const mailProcessingService = new MailProcessingService(
|
||||
new MailRepository(),
|
||||
imapService,
|
||||
clientNotification,
|
||||
config,
|
||||
smtpService
|
||||
smtpService,
|
||||
verificationStore
|
||||
)
|
||||
debug('Mail processing service initialized')
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const config = {
|
|||
|
||||
http: {
|
||||
port: Number(process.env.HTTP_PORT),
|
||||
baseUrl: parseValue(process.env.HTTP_BASE_URL) || 'http://localhost:3000',
|
||||
branding: parseValue(process.env.HTTP_BRANDING),
|
||||
displaySort: Number(process.env.HTTP_DISPLAY_SORT),
|
||||
hideOther: parseBool(process.env.HTTP_HIDE_OTHER)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const config = require('./config')
|
||||
const debug = require('debug')('48hr-email:helper')
|
||||
const crypto = require('crypto')
|
||||
|
||||
class Helper {
|
||||
|
||||
|
|
@ -180,6 +181,57 @@ class Helper {
|
|||
</label>`
|
||||
return handling
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random verification token
|
||||
* @returns {string} - 32-byte hex token (64 characters)
|
||||
*/
|
||||
generateVerificationToken() {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
debug('Generated verification token')
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an email address for use in a cookie
|
||||
* Uses HMAC-SHA256 with the session secret
|
||||
* @param {string} email - Email address to sign
|
||||
* @returns {string} - HMAC signature (hex)
|
||||
*/
|
||||
signCookie(email) {
|
||||
const secret = config.lock.sessionSecret
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
hmac.update(email.toLowerCase())
|
||||
const signature = hmac.digest('hex')
|
||||
debug(`Signed cookie for email: ${email}`)
|
||||
return signature
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a cookie signature for an email address
|
||||
* @param {string} email - Email address to verify
|
||||
* @param {string} signature - HMAC signature to verify
|
||||
* @returns {boolean} - True if signature is valid
|
||||
*/
|
||||
verifyCookieSignature(email, signature) {
|
||||
if (!email || !signature) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedSignature = this.signCookie(email)
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature, 'hex'),
|
||||
Buffer.from(expectedSignature, 'hex')
|
||||
)
|
||||
} catch (error) {
|
||||
// timingSafeEqual throws if buffers are different lengths
|
||||
debug(`Cookie signature verification failed: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Helper
|
||||
module.exports = Helper
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ const helper = new(Helper)
|
|||
|
||||
|
||||
class MailProcessingService extends EventEmitter {
|
||||
constructor(mailRepository, imapService, clientNotification, config, smtpService = null) {
|
||||
constructor(mailRepository, imapService, clientNotification, config, smtpService = null, verificationStore = null) {
|
||||
super()
|
||||
this.mailRepository = mailRepository
|
||||
this.clientNotification = clientNotification
|
||||
this.imapService = imapService
|
||||
this.config = config
|
||||
this.smtpService = smtpService
|
||||
this.verificationStore = verificationStore
|
||||
this.helper = new(Helper)
|
||||
|
||||
// Cached methods:
|
||||
this._initCache()
|
||||
|
|
@ -277,6 +279,94 @@ class MailProcessingService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate email verification for forwarding
|
||||
* Sends verification email to destination address
|
||||
* @param {string} sourceAddress - The inbox address requesting forwarding
|
||||
* @param {string} destinationEmail - The email address to verify and forward to
|
||||
* @param {Array<number>} uids - Array of email UIDs to forward (optional, for context)
|
||||
* @returns {Promise<{success: boolean, error?: string, cooldownSeconds?: number}>}
|
||||
*/
|
||||
async initiateForwardVerification(sourceAddress, destinationEmail, uids = []) {
|
||||
// Check if verification store is available
|
||||
if (!this.verificationStore) {
|
||||
debug('Verification store not available')
|
||||
return {
|
||||
success: false,
|
||||
error: 'Email verification is not configured'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if SMTP service is available
|
||||
if (!this.smtpService) {
|
||||
debug('SMTP service not configured')
|
||||
return {
|
||||
success: false,
|
||||
error: 'Email forwarding is not configured. Please configure SMTP settings.'
|
||||
}
|
||||
}
|
||||
|
||||
// Check rate limit (5-minute cooldown)
|
||||
const canRequest = this.verificationStore.canRequestVerification(destinationEmail)
|
||||
if (!canRequest) {
|
||||
const lastRequest = this.verificationStore.getLastVerificationTime(destinationEmail)
|
||||
const cooldownMs = 5 * 60 * 1000
|
||||
const elapsed = Date.now() - lastRequest
|
||||
const remainingSeconds = Math.ceil((cooldownMs - elapsed) / 1000)
|
||||
|
||||
debug(`Verification rate limit hit for ${destinationEmail}, ${remainingSeconds}s remaining`)
|
||||
return {
|
||||
success: false,
|
||||
error: `Please wait ${remainingSeconds} seconds before requesting another verification email`,
|
||||
cooldownSeconds: remainingSeconds
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate verification token
|
||||
const token = this.helper.generateVerificationToken()
|
||||
|
||||
// Store verification with metadata
|
||||
this.verificationStore.createVerification(token, destinationEmail, {
|
||||
sourceAddress,
|
||||
uids,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Send verification email
|
||||
const baseUrl = this.config.http.baseUrl
|
||||
const branding = this.config.http.branding[0] || '48hr.email'
|
||||
|
||||
debug(`Sending verification email to ${destinationEmail} for source ${sourceAddress}`)
|
||||
const result = await this.smtpService.sendVerificationEmail(
|
||||
destinationEmail,
|
||||
token,
|
||||
baseUrl,
|
||||
branding
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
debug(`Verification email sent successfully. MessageId: ${result.messageId}`)
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.messageId
|
||||
}
|
||||
} else {
|
||||
debug(`Failed to send verification email: ${result.error}`)
|
||||
return {
|
||||
success: false,
|
||||
error: result.error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debug('Error initiating verification:', error.message)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to send verification email: ${error.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_saveToFile(mails, filename) {
|
||||
const fs = require('fs')
|
||||
fs.writeFile(filename, JSON.stringify(mails), err => {
|
||||
|
|
|
|||
|
|
@ -217,6 +217,111 @@ ${mail.html}
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email to destination address
|
||||
* @param {string} destinationEmail - Email address to verify
|
||||
* @param {string} token - Verification token
|
||||
* @param {string} baseUrl - Base URL for verification link
|
||||
* @param {string} branding - Service branding name
|
||||
* @returns {Promise<{success: boolean, error?: string, messageId?: string}>}
|
||||
*/
|
||||
async sendVerificationEmail(destinationEmail, token, baseUrl, branding = '48hr.email') {
|
||||
if (!this.transporter) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SMTP is not configured. Please configure SMTP settings to enable forwarding.'
|
||||
}
|
||||
}
|
||||
|
||||
const verificationLink = `${baseUrl}/inbox/verify?token=${token}`
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #2c3e50; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
|
||||
.content { background: #f9f9f9; padding: 30px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 5px 5px; }
|
||||
.button { display: inline-block; background: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; margin: 20px 0; font-weight: bold; }
|
||||
.button:hover { background: #2980b9; }
|
||||
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 20px 0; }
|
||||
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }
|
||||
code { background: #e8e8e8; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h2>🔐 Verify Your Email Address</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>You requested to use <strong>${this._escapeHtml(destinationEmail)}</strong> as a forwarding destination on <strong>${this._escapeHtml(branding)}</strong>.</p>
|
||||
|
||||
<p>To verify ownership of this email address and enable forwarding for 24 hours, please click the button below:</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="${verificationLink}" class="button">Verify Email Address</a>
|
||||
</div>
|
||||
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p><code>${verificationLink}</code></p>
|
||||
|
||||
<div class="warning">
|
||||
⚠️ <strong>Important:</strong> This verification link expires in <strong>15 minutes</strong>. Once verified, you'll be able to forward emails to this address for 24 hours.
|
||||
</div>
|
||||
|
||||
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated message from ${this._escapeHtml(branding)}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const textContent = `
|
||||
Verify Your Email Address
|
||||
|
||||
You requested to use ${destinationEmail} as a forwarding destination on ${branding}.
|
||||
|
||||
To verify ownership of this email address and enable forwarding for 24 hours, please visit:
|
||||
|
||||
${verificationLink}
|
||||
|
||||
IMPORTANT: This verification link expires in 15 minutes. Once verified, you'll be able to forward emails to this address for 24 hours.
|
||||
|
||||
If you didn't request this verification, you can safely ignore this email.
|
||||
|
||||
---
|
||||
This is an automated message from ${branding}
|
||||
`
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: `"${branding} Forwarding Service" <${this.config.smtp.user}>`,
|
||||
to: destinationEmail,
|
||||
subject: `${branding} - Verify your email for forwarding`,
|
||||
text: textContent,
|
||||
html: htmlContent
|
||||
})
|
||||
|
||||
debug(`Verification email sent to ${destinationEmail}, messageId: ${info.messageId}`)
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId
|
||||
}
|
||||
} catch (error) {
|
||||
debug(`Failed to send verification email: ${error.message}`)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to send verification email: ${error.message}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SmtpService
|
||||
|
|
|
|||
161
domain/verification-store.js
Normal file
161
domain/verification-store.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
const debug = require('debug')('48hr-email:verification-store')
|
||||
|
||||
/**
|
||||
* In-memory store for email verification tokens
|
||||
* Manages pending verifications with expiration and rate limiting
|
||||
*/
|
||||
class VerificationStore {
|
||||
constructor() {
|
||||
// Map of token -> verification data
|
||||
this.verifications = new Map()
|
||||
// Map of destinationEmail -> last verification request timestamp
|
||||
this.lastVerificationRequests = new Map()
|
||||
|
||||
// Cleanup expired tokens every 5 minutes
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup()
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
debug('VerificationStore initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new verification entry
|
||||
* @param {string} token - Unique verification token
|
||||
* @param {string} destinationEmail - Email address being verified
|
||||
* @param {Object} metadata - Additional data (sourceAddress, uids, etc.)
|
||||
* @returns {Object} - Verification entry
|
||||
*/
|
||||
createVerification(token, destinationEmail, metadata = {}) {
|
||||
const now = Date.now()
|
||||
const expiresAt = now + (15 * 60 * 1000) // 15 minutes
|
||||
|
||||
const verification = {
|
||||
token,
|
||||
destinationEmail: destinationEmail.toLowerCase(),
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
metadata
|
||||
}
|
||||
|
||||
this.verifications.set(token, verification)
|
||||
this.lastVerificationRequests.set(destinationEmail.toLowerCase(), now)
|
||||
|
||||
debug(`Created verification for ${destinationEmail}, token expires in 15 minutes`)
|
||||
return verification
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a token and return the verification data if valid
|
||||
* @param {string} token - Token to verify
|
||||
* @returns {Object|null} - Verification data or null if invalid/expired
|
||||
*/
|
||||
verifyToken(token) {
|
||||
const verification = this.verifications.get(token)
|
||||
|
||||
if (!verification) {
|
||||
debug(`Token not found: ${token}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now > verification.expiresAt) {
|
||||
debug(`Token expired: ${token}`)
|
||||
this.verifications.delete(token)
|
||||
return null
|
||||
}
|
||||
|
||||
debug(`Token verified successfully for ${verification.destinationEmail}`)
|
||||
// Remove token after successful verification (one-time use)
|
||||
this.verifications.delete(token)
|
||||
return verification
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last verification request time for an email
|
||||
* @param {string} destinationEmail - Email address to check
|
||||
* @returns {number|null} - Timestamp of last request or null
|
||||
*/
|
||||
getLastVerificationTime(destinationEmail) {
|
||||
return this.lastVerificationRequests.get(destinationEmail.toLowerCase()) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enough time has passed since last verification request
|
||||
* @param {string} destinationEmail - Email address to check
|
||||
* @param {number} cooldownMs - Cooldown period in milliseconds (default: 5 minutes)
|
||||
* @returns {boolean} - True if can request verification, false if still in cooldown
|
||||
*/
|
||||
canRequestVerification(destinationEmail, cooldownMs = 5 * 60 * 1000) {
|
||||
const lastRequest = this.getLastVerificationTime(destinationEmail)
|
||||
|
||||
if (!lastRequest) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastRequest = now - lastRequest
|
||||
const canRequest = timeSinceLastRequest >= cooldownMs
|
||||
|
||||
if (!canRequest) {
|
||||
const remainingSeconds = Math.ceil((cooldownMs - timeSinceLastRequest) / 1000)
|
||||
debug(`Verification cooldown active for ${destinationEmail}, ${remainingSeconds}s remaining`)
|
||||
}
|
||||
|
||||
return canRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens and old rate limit entries
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now()
|
||||
let expiredCount = 0
|
||||
let rateLimitCleanupCount = 0
|
||||
|
||||
// Clean expired tokens
|
||||
for (const [token, verification] of this.verifications.entries()) {
|
||||
if (now > verification.expiresAt) {
|
||||
this.verifications.delete(token)
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Clean old rate limit entries (older than 1 hour)
|
||||
for (const [email, timestamp] of this.lastVerificationRequests.entries()) {
|
||||
if (now - timestamp > 60 * 60 * 1000) {
|
||||
this.lastVerificationRequests.delete(email)
|
||||
rateLimitCleanupCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount > 0 || rateLimitCleanupCount > 0) {
|
||||
debug(`Cleanup: removed ${expiredCount} expired tokens, ${rateLimitCleanupCount} old rate limit entries`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the store
|
||||
* @returns {Object} - Store statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
pendingVerifications: this.verifications.size,
|
||||
rateLimitEntries: this.lastVerificationRequests.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the store and cleanup interval
|
||||
*/
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
}
|
||||
this.verifications.clear()
|
||||
this.lastVerificationRequests.clear()
|
||||
debug('VerificationStore destroyed')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = VerificationStore
|
||||
|
|
@ -1016,6 +1016,24 @@ label {
|
|||
}
|
||||
|
||||
|
||||
/* Verification Messages */
|
||||
|
||||
.verification-message {
|
||||
color: #3498db;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
border-left: 3px solid #3498db;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.verification-message strong {
|
||||
color: #2980b9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* Remove Lock Button Styles */
|
||||
|
||||
.modal-button-danger {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
|
|||
// Check for forward all success flag
|
||||
const forwardAllSuccess = req.query.forwardedAll ? parseInt(req.query.forwardedAll) : null
|
||||
|
||||
// Check for verification sent flag
|
||||
const verificationSent = req.query.verificationSent === 'true'
|
||||
const verificationEmail = req.query.email || ''
|
||||
|
||||
res.render('inbox', {
|
||||
title: `${config.http.branding[0]} | ` + req.params.address,
|
||||
purgeTime: purgeTime,
|
||||
|
|
@ -144,7 +148,9 @@ router.get('^/:address([^@/]+@[^@/]+)', sanitizeAddress, validateDomain, checkLo
|
|||
expiryUnit: config.email.purgeTime.unit,
|
||||
refreshInterval: config.imap.refreshIntervalSeconds,
|
||||
errorMessage: errorMessage,
|
||||
forwardAllSuccess: forwardAllSuccess
|
||||
forwardAllSuccess: forwardAllSuccess,
|
||||
verificationSent: verificationSent,
|
||||
verificationEmail: verificationEmail
|
||||
})
|
||||
} catch (error) {
|
||||
debug(`Error loading inbox for ${req.params.address}:`, error.message)
|
||||
|
|
@ -195,6 +201,10 @@ router.get(
|
|||
// Check for forward success flag
|
||||
const forwardSuccess = req.query.forwarded === 'true'
|
||||
|
||||
// Check for verification sent flag
|
||||
const verificationSent = req.query.verificationSent === 'true'
|
||||
const verificationEmail = req.query.email || ''
|
||||
|
||||
debug(`Rendering email view for UID ${req.params.uid}`)
|
||||
res.render('mail', {
|
||||
title: mail.subject + " | " + req.params.address,
|
||||
|
|
@ -210,7 +220,9 @@ router.get(
|
|||
isLocked: isLocked,
|
||||
hasAccess: hasAccess,
|
||||
errorMessage: errorMessage,
|
||||
forwardSuccess: forwardSuccess
|
||||
forwardSuccess: forwardSuccess,
|
||||
verificationSent: verificationSent,
|
||||
verificationEmail: verificationEmail
|
||||
})
|
||||
} else {
|
||||
debug(`Email ${req.params.uid} not found for ${req.params.address}`)
|
||||
|
|
@ -427,21 +439,48 @@ router.post(
|
|||
const { destinationEmail } = req.body
|
||||
const uid = parseInt(req.params.uid, 10)
|
||||
|
||||
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail}`)
|
||||
// Check if destination email is verified via signed cookie
|
||||
const verifiedEmail = req.signedCookies.verified_email
|
||||
|
||||
const result = await mailProcessingService.forwardEmail(
|
||||
req.params.address,
|
||||
uid,
|
||||
destinationEmail
|
||||
)
|
||||
if (verifiedEmail && verifiedEmail.toLowerCase() === destinationEmail.toLowerCase()) {
|
||||
// Email is verified, proceed with forwarding
|
||||
debug(`Forwarding email ${uid} from ${req.params.address} to ${destinationEmail} (verified)`)
|
||||
|
||||
if (result.success) {
|
||||
debug(`Email ${uid} forwarded successfully to ${destinationEmail}`)
|
||||
return res.redirect(`/inbox/${req.params.address}/${uid}?forwarded=true`)
|
||||
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}`)
|
||||
}
|
||||
} else {
|
||||
debug(`Failed to forward email ${uid}: ${result.error}`)
|
||||
req.session.errorMessage = result.error
|
||||
return res.redirect(`/inbox/${req.params.address}/${uid}`)
|
||||
// Email not verified, initiate verification flow
|
||||
debug(`Email ${destinationEmail} not verified, initiating verification`)
|
||||
|
||||
const verificationResult = await mailProcessingService.initiateForwardVerification(
|
||||
req.params.address,
|
||||
destinationEmail, [uid]
|
||||
)
|
||||
|
||||
if (verificationResult.success) {
|
||||
debug(`Verification email sent to ${destinationEmail}`)
|
||||
return res.redirect(`/inbox/${req.params.address}/${uid}?verificationSent=true&email=${encodeURIComponent(destinationEmail)}`)
|
||||
} else if (verificationResult.cooldownSeconds) {
|
||||
debug(`Verification rate limited for ${destinationEmail}`)
|
||||
req.session.errorMessage = verificationResult.error
|
||||
return res.redirect(`/inbox/${req.params.address}/${uid}`)
|
||||
} else {
|
||||
debug(`Failed to send verification email: ${verificationResult.error}`)
|
||||
req.session.errorMessage = verificationResult.error || 'Failed to send verification email'
|
||||
return res.redirect(`/inbox/${req.params.address}/${uid}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debug(`Error forwarding email ${req.params.uid}: ${error.message}`)
|
||||
|
|
@ -472,7 +511,38 @@ router.post(
|
|||
const mailProcessingService = req.app.get('mailProcessingService')
|
||||
const { destinationEmail } = req.body
|
||||
|
||||
debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail}`)
|
||||
// Check if destination email is verified via signed cookie
|
||||
const verifiedEmail = req.signedCookies.verified_email
|
||||
|
||||
if (!verifiedEmail || verifiedEmail.toLowerCase() !== destinationEmail.toLowerCase()) {
|
||||
// Email not verified, initiate verification flow
|
||||
debug(`Email ${destinationEmail} not verified, initiating verification for forward-all`)
|
||||
|
||||
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
|
||||
const uids = mailSummaries.map(m => m.uid)
|
||||
|
||||
const verificationResult = await mailProcessingService.initiateForwardVerification(
|
||||
req.params.address,
|
||||
destinationEmail,
|
||||
uids
|
||||
)
|
||||
|
||||
if (verificationResult.success) {
|
||||
debug(`Verification email sent to ${destinationEmail}`)
|
||||
return res.redirect(`/inbox/${req.params.address}?verificationSent=true&email=${encodeURIComponent(destinationEmail)}`)
|
||||
} else if (verificationResult.cooldownSeconds) {
|
||||
debug(`Verification rate limited for ${destinationEmail}`)
|
||||
req.session.errorMessage = verificationResult.error
|
||||
return res.redirect(`/inbox/${req.params.address}`)
|
||||
} else {
|
||||
debug(`Failed to send verification email: ${verificationResult.error}`)
|
||||
req.session.errorMessage = verificationResult.error || 'Failed to send verification email'
|
||||
return res.redirect(`/inbox/${req.params.address}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Email is verified, proceed with bulk forwarding
|
||||
debug(`Forwarding all emails from ${req.params.address} to ${destinationEmail} (verified)`)
|
||||
|
||||
const mailSummaries = await mailProcessingService.getMailSummaries(req.params.address)
|
||||
|
||||
|
|
@ -542,5 +612,78 @@ router.get(
|
|||
}
|
||||
)
|
||||
|
||||
// GET route for email verification (token verification)
|
||||
router.get('/verify', async(req, res, next) => {
|
||||
try {
|
||||
const { token } = req.query
|
||||
|
||||
if (!token) {
|
||||
debug('Verification attempt without token')
|
||||
req.session.errorMessage = 'Verification token is required'
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
const verificationStore = req.app.get('verificationStore')
|
||||
if (!verificationStore) {
|
||||
debug('Verification store not available')
|
||||
req.session.errorMessage = 'Email verification is not configured'
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
const verification = verificationStore.verifyToken(token)
|
||||
|
||||
if (!verification) {
|
||||
debug(`Invalid or expired verification token: ${token}`)
|
||||
req.session.errorMessage = 'This verification link is invalid or has expired. Please request a new verification email.'
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
// Token is valid, set signed cookie
|
||||
const destinationEmail = verification.destinationEmail
|
||||
const cookieMaxAge = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
|
||||
res.cookie('verified_email', destinationEmail, {
|
||||
maxAge: cookieMaxAge,
|
||||
httpOnly: true,
|
||||
signed: true,
|
||||
sameSite: 'lax'
|
||||
})
|
||||
|
||||
debug(`Email ${destinationEmail} verified successfully, cookie set for 24 hours`)
|
||||
|
||||
// Redirect to success page
|
||||
return res.redirect(`/inbox/verify-success?email=${encodeURIComponent(destinationEmail)}`)
|
||||
} catch (error) {
|
||||
debug(`Error during verification: ${error.message}`)
|
||||
console.error('Error during email verification', error)
|
||||
req.session.errorMessage = 'An error occurred during verification'
|
||||
res.redirect('/')
|
||||
}
|
||||
})
|
||||
|
||||
// GET route for verification success page
|
||||
router.get('/verify-success', async(req, res) => {
|
||||
const { email } = req.query
|
||||
|
||||
if (!email) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
const config = req.app.get('config')
|
||||
const mailProcessingService = req.app.get('mailProcessingService')
|
||||
const count = await mailProcessingService.getCount()
|
||||
const largestUid = await req.app.locals.imapService.getLargestUid()
|
||||
const totalcount = helper.countElementBuilder(count, largestUid)
|
||||
|
||||
res.render('verify-success', {
|
||||
title: `Email Verified | ${config.http.branding[0]}`,
|
||||
email: email,
|
||||
branding: config.http.branding,
|
||||
purgeTime: purgeTime,
|
||||
totalcount: totalcount
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
module.exports = router
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
✓ Successfully forwarded {{ forwardAllSuccess }} email(s)!
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verificationSent %}
|
||||
<div class="verification-message">
|
||||
📧 Verification email sent to <strong>{{ verificationEmail }}</strong>. Please check your inbox and click the verification link (expires in 15 minutes).
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if errorMessage %}
|
||||
<div class="unlock-error">
|
||||
{{ errorMessage }}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@
|
|||
✓ Email forwarded successfully!
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verificationSent %}
|
||||
<div class="verification-message">
|
||||
📧 Verification email sent to <strong>{{ verificationEmail }}</strong>. Please check your inbox and click the verification link (expires in 15 minutes).
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mail-container">
|
||||
<div class="mail-header">
|
||||
<h1 class="mail-subject">{{ mail.subject }}</h1>
|
||||
|
|
|
|||
143
infrastructure/web/views/verify-success.twig
Normal file
143
infrastructure/web/views/verify-success.twig
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block header %}
|
||||
<div class="action-links">
|
||||
<a href="/" aria-label="Return to home">← Return to Home</a>
|
||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode">
|
||||
<svg class="theme-icon theme-icon-dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<svg class="theme-icon theme-icon-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="verification-success-container">
|
||||
<div class="verification-success-card">
|
||||
<div class="verification-success-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>Email Verified Successfully!</h1>
|
||||
|
||||
<div class="verification-details">
|
||||
<p>You have verified ownership of:</p>
|
||||
<p class="verified-email">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="verification-info">
|
||||
<p>✓ You can now forward emails to this address for the next <strong>24 hours</strong></p>
|
||||
<p>✓ After 24 hours, you'll need to verify again</p>
|
||||
<p>✓ You can close this tab and return to your inbox</p>
|
||||
</div>
|
||||
|
||||
<div class="verification-actions">
|
||||
<a href="/" class="button button-primary">Return to {{ branding[0] }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verification-success-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.verification-success-card {
|
||||
background: var(--background-color);
|
||||
border: 2px solid var(--success-color, #28a745);
|
||||
border-radius: 8px;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.verification-success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
color: var(--success-color, #28a745);
|
||||
}
|
||||
|
||||
.verification-success-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.verification-success-card h1 {
|
||||
color: var(--success-color, #28a745);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.verification-details {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--code-background, #f5f5f5);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.verification-details p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.verified-email {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.verification-info {
|
||||
margin: 2rem 0;
|
||||
text-align: left;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.verification-info p {
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.verification-actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
display: inline-block;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--primary-hover-color, #0056b3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.verification-success-card {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.verification-success-card h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
@ -3,6 +3,7 @@ const http = require('http')
|
|||
const debug = require('debug')('48hr-email:server')
|
||||
const express = require('express')
|
||||
const session = require('express-session')
|
||||
const cookieParser = require('cookie-parser')
|
||||
const logger = require('morgan')
|
||||
const Twig = require('twig')
|
||||
const compression = require('compression')
|
||||
|
|
@ -40,20 +41,20 @@ app.use(logger('dev'))
|
|||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: false }))
|
||||
|
||||
// Session support for inbox locking
|
||||
if (config.lock.enabled) {
|
||||
const session = require('express-session')
|
||||
app.use(session({
|
||||
secret: config.lock.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
||||
}))
|
||||
}
|
||||
// Cookie parser for signed cookies (email verification)
|
||||
app.use(cookieParser(config.lock.sessionSecret))
|
||||
|
||||
// Session support (always enabled for forward verification and inbox locking)
|
||||
app.use(session({
|
||||
secret: config.lock.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
||||
}))
|
||||
|
||||
// Clear session when user goes Home so locked inboxes require password again
|
||||
app.get('/', (req, res, next) => {
|
||||
if (config.lock.enabled && req.session) {
|
||||
if (req.session) {
|
||||
req.session.destroy(() => next())
|
||||
} else {
|
||||
next()
|
||||
|
|
@ -149,4 +150,4 @@ server.on('listening', () => {
|
|||
debug('Listening on ' + bind)
|
||||
})
|
||||
|
||||
module.exports = { app, io, server }
|
||||
module.exports = { app, io, server }
|
||||
|
|
|
|||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"debug": "^4.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.22.1",
|
||||
|
|
@ -1837,6 +1838,25 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
|
|
|
|||
25
package.json
25
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "48hr.email",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"private": false,
|
||||
"description": "48hr.email is your favorite open-source tempmail client.",
|
||||
"keywords": [
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"debug": "^4.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.22.1",
|
||||
|
|
@ -67,17 +68,15 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}
|
||||
]
|
||||
"overrides": [{
|
||||
"files": "public/javascripts/*.js",
|
||||
"esnext": false,
|
||||
"env": [
|
||||
"browser"
|
||||
],
|
||||
"globals": [
|
||||
"io"
|
||||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue