Files
picpeak/backend/src/services/emailProcessor.js
T
paul f6e5a454ae
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
feat: enhance security logging and ensure rate limit blocks are properly tracked
- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 19:25:42 +02:00

549 lines
16 KiB
JavaScript

const nodemailer = require('nodemailer');
const Handlebars = require('handlebars');
const { db } = require('../database/db');
const logger = require('../utils/logger');
let transporter = null;
let lastConfigHash = null;
// Generate hash from config for change detection
function generateConfigHash(config) {
const crypto = require('crypto');
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
return crypto.createHash('md5').update(configString).digest('hex');
}
// Initialize transporter from database config
async function initializeTransporter(forceReinit = false) {
try {
const config = await db('email_configs').first();
if (!config) {
logger.warn('No email configuration found');
return null;
}
// Check if configuration has changed
const currentConfigHash = generateConfigHash(config);
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
// Configuration hasn't changed, return existing transporter
return transporter;
}
// Configuration has changed or first initialization
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
secure: config.smtp_secure,
auth: config.smtp_user ? {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined
});
// Verify configuration
await transporter.verify();
logger.info('Email transporter initialized successfully');
// Update the config hash
lastConfigHash = currentConfigHash;
return transporter;
} catch (error) {
logger.error('Failed to initialize email transporter:', error);
transporter = null;
lastConfigHash = null;
return null;
}
}
// Get the appropriate language for a recipient
async function getRecipientLanguage(email, eventId = null) {
// First priority: Check event language setting if eventId is provided
if (eventId) {
try {
const event = await db('events').where('id', eventId).first();
if (event && event.language) {
return event.language;
}
} catch (error) {
logger.error('Error fetching event language:', error);
}
}
// Second priority: Check app_settings for general default language
try {
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
.first();
if (langSetting && langSetting.setting_value) {
return langSetting.setting_value;
}
} catch (error) {
logger.error('Error fetching app settings language:', error);
}
// Third priority: Check email configs for default language
try {
const emailConfig = await db('email_configs').first();
if (emailConfig && emailConfig.default_language) {
return emailConfig.default_language;
}
} catch (error) {
logger.error('Error fetching email config language:', error);
}
// Fourth priority: Check if the email domain suggests German
if (email) {
const germanDomains = ['.de', '.at', '.ch', '.li'];
const domain = email.toLowerCase();
if (germanDomains.some(d => domain.endsWith(d))) {
return 'de';
}
}
return 'en'; // Default to English
}
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
// Fall back to non-language-specific fields for backward compatibility
let subject = template[subjectField] || template.subject || '';
let htmlBody = template[htmlField] || template.body_html || '';
let textBody = template[textField] || template.body_text || '';
// Process variables before template compilation
const processedVariables = { ...variables };
// Handle password security message
if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = language === 'de'
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
}
// Format dates if they exist
if (processedVariables.event_date) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
}
if (processedVariables.expiry_date) {
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
}
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
let logoUrl = '';
let companyName = 'PicPeak';
try {
const brandingSettings = await db('app_settings')
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
.select('setting_key', 'setting_value');
brandingSettings.forEach(setting => {
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
try {
logoUrl = JSON.parse(setting.setting_value);
} catch (e) {
logoUrl = setting.setting_value;
}
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
try {
companyName = JSON.parse(setting.setting_value);
} catch (e) {
companyName = setting.setting_value;
}
}
});
} catch (error) {
logger.error('Error fetching branding settings:', error);
}
// If no custom logo, use default PicPeak logo
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
// Compile templates with Handlebars
const subjectTemplate = Handlebars.compile(subject);
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Process templates with processedVariables (includes formatted dates and security messages)
subject = subjectTemplate(processedVariables);
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
// Wrap HTML body in styled template
const styledHtmlBody = `
<!DOCTYPE html>
<html lang="${language}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.email-wrapper {
background-color: #f5f5f5;
padding: 40px 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.email-header {
background-color: #5C8762;
padding: 30px;
text-align: center;
}
.logo {
max-width: 180px;
height: auto;
margin-bottom: 10px;
}
.email-content {
padding: 40px 30px;
}
.email-content h2 {
color: #5C8762;
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
}
.email-content p {
line-height: 1.6;
margin-bottom: 15px;
}
.email-content ul {
background-color: #f9f9f9;
padding: 20px 20px 20px 40px;
border-radius: 5px;
margin: 20px 0;
}
.email-content li {
margin-bottom: 10px;
}
.button {
display: inline-block;
padding: 12px 30px;
background-color: #5C8762;
color: white !important;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
margin: 20px 0;
}
.button:hover {
background-color: #4a6f4f;
}
.email-footer {
background-color: #f9f9f9;
padding: 30px;
text-align: center;
border-top: 1px solid #eee;
}
.email-footer img {
max-width: 120px;
height: auto;
margin-bottom: 15px;
opacity: 0.8;
}
.email-footer p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
a {
color: #5C8762;
text-decoration: underline;
}
a:hover {
color: #4a6f4f;
}
strong {
color: #333;
}
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 20px 10px;
}
.email-content {
padding: 30px 20px;
}
.email-header {
padding: 20px;
}
.logo {
max-width: 150px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<img src="${logoFullUrl}" alt="${companyName}" class="logo">
</div>
<div class="email-content">
${htmlBody}
</div>
<div class="email-footer">
<img src="${logoFullUrl}" alt="${companyName}">
<p>${companyName}</p>
<p style="font-size: 12px; color: #999;">© ${new Date().getFullYear()} ${companyName}. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>`;
return { subject, htmlBody: styledHtmlBody, textBody };
}
// Send email using template
async function sendTemplateEmail(to, templateKey, variables) {
try {
// Always check for configuration changes before sending
transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
// Get email template
const template = await db('email_templates')
.where('template_key', templateKey)
.first();
if (!template) {
throw new Error(`Email template '${templateKey}' not found`);
}
// Get email config for from address
const config = await db('email_configs').first();
if (!config) {
throw new Error('Email configuration not found');
}
// Determine recipient language (pass eventId if available in variables)
const language = await getRecipientLanguage(to, variables.eventId || null);
// Process template with variables
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
// Send email
const info = await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: to,
subject: subject,
html: htmlBody,
text: textBody || htmlBody.replace(/<[^>]*>/g, '') // Strip HTML if no text version
});
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
return { success: true, messageId: info.messageId, language };
} catch (error) {
logger.error('Error sending template email:', error);
throw error;
}
}
// Process email queue
async function processEmailQueue() {
logger.info('Email queue processor: Checking for pending emails...');
try {
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
logger.info('Transporter not initialized, attempting to initialize...');
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
}
}
let pendingEmails = [];
try {
pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.orderBy('created_at', 'asc')
.limit(10);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
return;
}
if (pendingEmails.length === 0) {
logger.info('Email queue processor: No pending emails found');
return;
}
logger.info(`Processing ${pendingEmails.length} emails from queue`);
for (const email of pendingEmails) {
try {
const emailData = typeof email.email_data === 'string'
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
);
// Mark as sent
await db('email_queue')
.where('id', email.id)
.update({
status: 'sent',
sent_at: new Date()
});
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
// Increment retry count
try {
await db('email_queue')
.where('id', email.id)
.update({
retry_count: email.retry_count + 1,
error_message: error.message
});
} catch (updateError) {
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
// If update fails due to column issue, try without any potential auto-added fields
if (updateError.message && updateError.message.includes('updated_at')) {
logger.warn('Detected updated_at column issue, attempting raw query...');
await db.raw(
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
[email.retry_count + 1, error.message, email.id]
);
}
}
logger.error(`Failed to send email ${email.id}:`, error);
}
}
} catch (error) {
logger.error('Error processing email queue:', error);
}
}
// Queue an email for sending
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
await db('email_queue').insert({
event_id: eventId,
recipient_email: recipientEmail,
email_type: emailType,
email_data: JSON.stringify(emailData),
status: 'pending',
retry_count: 0,
created_at: new Date()
});
logger.info(`Email queued: ${emailType} to ${recipientEmail}`);
} catch (error) {
logger.error('Error queueing email:', error);
throw error;
}
}
// Test email connection
async function testEmailConnection() {
try {
if (!transporter) {
await initializeTransporter();
}
if (!transporter) {
return false;
}
await transporter.verify();
return true;
} catch (error) {
logger.error('Email connection test failed:', error);
return false;
}
}
// Start email queue processor
let emailQueueInterval = null;
function startEmailQueueProcessor() {
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
}
function stopEmailQueueProcessor() {
if (emailQueueInterval) {
clearInterval(emailQueueInterval);
emailQueueInterval = null;
logger.info('Email queue processor stopped');
}
}
// Initialize on module load - DISABLED for production startup
// This will be called from server.js after database is ready
// initializeTransporter().then(() => {
// startEmailQueueProcessor();
// });
module.exports = {
initializeTransporter,
startEmailQueueProcessor,
sendTemplateEmail,
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
testEmailConnection
};