Tonne of updates to influence, the configs, update the homepage, and generally just did more bug testing with Influence
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const emailTemplates = require('./emailTemplates');
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
@@ -6,6 +7,33 @@ class EmailService {
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
const transporterConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
// Add auth if credentials are provided
|
||||
if (process.env.SMTP_USER && process.env.SMTP_PASS) {
|
||||
transporterConfig.auth = {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
};
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransporter(transporterConfig);
|
||||
|
||||
console.log('Email transporter initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize email transporter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
@@ -47,32 +75,80 @@ class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(emailOptions) {
|
||||
async sendEmail(emailOptions, isTest = false) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email transporter not initialized');
|
||||
}
|
||||
|
||||
let to = emailOptions.to;
|
||||
let subject = emailOptions.subject;
|
||||
|
||||
// Test mode - redirect emails and modify subject
|
||||
const testMode = isTest || process.env.EMAIL_TEST_MODE === 'true';
|
||||
if (testMode) {
|
||||
const originalTo = to;
|
||||
to = process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
subject = `[TEST - Original: ${originalTo}] ${subject}`;
|
||||
|
||||
console.log(`Email redirected from ${originalTo} to ${to} (Test Mode)`);
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
to: emailOptions.to,
|
||||
to: to,
|
||||
replyTo: emailOptions.replyTo,
|
||||
subject: emailOptions.subject,
|
||||
subject: subject,
|
||||
text: emailOptions.text,
|
||||
html: emailOptions.html
|
||||
};
|
||||
|
||||
// Log email details in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Email Preview:', {
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
preview: emailOptions.text ? emailOptions.text.substring(0, 200) + '...' : 'No text content',
|
||||
testMode: testMode
|
||||
});
|
||||
}
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('Email sent successfully:', info.messageId);
|
||||
|
||||
// Log email to database if NocoDB service is available
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to, // Log original recipient
|
||||
subject: emailOptions.subject, // Log original subject
|
||||
status: 'sent',
|
||||
messageId: info.messageId,
|
||||
testMode: testMode,
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
response: info.response
|
||||
response: info.response,
|
||||
testMode: testMode,
|
||||
originalRecipient: testMode ? emailOptions.to : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
|
||||
// Log failed email attempt
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
testMode: isTest || process.env.EMAIL_TEST_MODE === 'true',
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
@@ -123,6 +199,155 @@ class EmailService {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
async logEmailSent(emailData) {
|
||||
try {
|
||||
// Use the existing logEmailSend method with the correct field structure
|
||||
const nocodbService = require('./nocodb');
|
||||
if (nocodbService && process.env.NOCODB_TABLE_EMAILS) {
|
||||
await nocodbService.logEmailSend({
|
||||
recipientEmail: emailData.to,
|
||||
senderName: emailData.senderName || 'System',
|
||||
senderEmail: emailData.senderEmail || process.env.SMTP_FROM_EMAIL,
|
||||
subject: emailData.subject,
|
||||
postalCode: emailData.postalCode || 'N/A',
|
||||
status: emailData.status || 'sent',
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: emailData.senderIP || 'localhost'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to log email:', error);
|
||||
// Don't throw - logging failure shouldn't prevent email sending
|
||||
}
|
||||
}
|
||||
|
||||
async previewEmail(emailOptions) {
|
||||
// Generate email preview without sending
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: emailOptions.text,
|
||||
html: emailOptions.html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null
|
||||
};
|
||||
}
|
||||
|
||||
// Template-based email methods
|
||||
async sendTemplatedEmail(templateName, templateVariables, emailOptions, isTest = false) {
|
||||
try {
|
||||
// Render the template
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
|
||||
// Prepare email options with rendered content
|
||||
const mailOptions = {
|
||||
...emailOptions,
|
||||
text: text,
|
||||
html: html
|
||||
};
|
||||
|
||||
// Send the email using existing sendEmail method
|
||||
return await this.sendEmail(mailOptions, isTest);
|
||||
} catch (error) {
|
||||
console.error('Failed to send templated email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode) {
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
SENDER_NAME: senderName,
|
||||
SENDER_EMAIL: senderEmail,
|
||||
POSTAL_CODE: postalCode
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||
}
|
||||
|
||||
async sendCampaignEmail(recipientEmail, userEmail, userName, postalCode, subject, message, campaignTitle, recipientName = null, recipientLevel = null) {
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
USER_NAME: userName,
|
||||
USER_EMAIL: userEmail,
|
||||
POSTAL_CODE: postalCode,
|
||||
CAMPAIGN_TITLE: campaignTitle,
|
||||
RECIPIENT_NAME: recipientName,
|
||||
RECIPIENT_LEVEL: recipientLevel
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: userEmail,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('campaign-email', templateVariables, emailOptions);
|
||||
}
|
||||
|
||||
async sendTestEmail(subject, message, testRecipient = null) {
|
||||
const recipient = testRecipient || process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipient,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: process.env.SMTP_FROM_EMAIL,
|
||||
subject: `[TEST EMAIL] ${subject}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('test-email', templateVariables, emailOptions, true);
|
||||
}
|
||||
|
||||
async previewTemplatedEmail(templateName, templateVariables, emailOptions) {
|
||||
try {
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: text,
|
||||
html: html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null,
|
||||
templateName: templateName,
|
||||
templateVariables: templateVariables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to preview templated email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
135
influence/app/services/emailTemplates.js
Normal file
135
influence/app/services/emailTemplates.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class EmailTemplateService {
|
||||
constructor() {
|
||||
this.templatesDir = path.join(__dirname, '../templates/email');
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
async loadTemplate(templateName, type = 'html') {
|
||||
const cacheKey = `${templateName}.${type}`;
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
|
||||
const template = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
// Cache the template
|
||||
this.cache.set(cacheKey, template);
|
||||
|
||||
return template;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load email template ${templateName}.${type}:`, error);
|
||||
throw new Error(`Email template not found: ${templateName}.${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
processTemplate(template, variables) {
|
||||
if (!template) return '';
|
||||
|
||||
let processed = template;
|
||||
|
||||
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
|
||||
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
|
||||
const value = variables[varName];
|
||||
// Check if value exists and is not empty string
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
// Recursively process the content inside the conditional block
|
||||
return this.processTemplate(content, variables);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Replace variables {{VARIABLE}}
|
||||
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
// Return the value or empty string if undefined
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
|
||||
// Handle line breaks in MESSAGE field for HTML templates
|
||||
if (variables.MESSAGE && processed.includes('{{MESSAGE}}')) {
|
||||
processed = processed.replace(/\{\{MESSAGE\}\}/g, variables.MESSAGE.replace(/\n/g, '<br>'));
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async render(templateName, variables) {
|
||||
try {
|
||||
// Load both HTML and text versions
|
||||
const [htmlTemplate, textTemplate] = await Promise.all([
|
||||
this.loadTemplate(templateName, 'html'),
|
||||
this.loadTemplate(templateName, 'txt')
|
||||
]);
|
||||
|
||||
// Add default variables
|
||||
const defaultVariables = {
|
||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence Tool',
|
||||
TIMESTAMP: new Date().toLocaleString(),
|
||||
...variables
|
||||
};
|
||||
|
||||
// Use processTemplate which handles conditionals properly
|
||||
return {
|
||||
html: this.processTemplate(htmlTemplate, defaultVariables),
|
||||
text: this.processTemplate(textTemplate, defaultVariables)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to render email template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available template names
|
||||
async getAvailableTemplates() {
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const templates = new Set();
|
||||
|
||||
files.forEach(file => {
|
||||
const ext = path.extname(file);
|
||||
const name = path.basename(file, ext);
|
||||
if (ext === '.html' || ext === '.txt') {
|
||||
templates.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(templates);
|
||||
} catch (error) {
|
||||
console.error('Failed to get available templates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear template cache (useful for development)
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
console.log('Email template cache cleared');
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
async templateExists(templateName) {
|
||||
try {
|
||||
const htmlPath = path.join(this.templatesDir, `${templateName}.html`);
|
||||
const txtPath = path.join(this.templatesDir, `${templateName}.txt`);
|
||||
|
||||
// Check if at least one format exists
|
||||
const [htmlExists, txtExists] = await Promise.all([
|
||||
fs.access(htmlPath).then(() => true).catch(() => false),
|
||||
fs.access(txtPath).then(() => true).catch(() => false)
|
||||
]);
|
||||
|
||||
return htmlExists || txtExists;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailTemplateService();
|
||||
@@ -202,7 +202,8 @@ class NocoDBService {
|
||||
'Subject': emailData.subject,
|
||||
'Postal Code': emailData.postalCode,
|
||||
'Status': emailData.status,
|
||||
'Sent At': emailData.timestamp
|
||||
'Sent At': emailData.timestamp,
|
||||
'Sender IP': emailData.senderIP || null // Add IP tracking for rate limiting
|
||||
};
|
||||
|
||||
await this.create(this.tableIds.emails, record);
|
||||
@@ -213,6 +214,25 @@ class NocoDBService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if an email was recently sent to this recipient from this IP
|
||||
async checkRecentEmailSend(senderIP, recipientEmail, windowMinutes = 5) {
|
||||
try {
|
||||
const windowStart = new Date(Date.now() - (windowMinutes * 60 * 1000)).toISOString();
|
||||
|
||||
const params = {
|
||||
where: `(Sender IP,eq,${senderIP})~and(Recipient Email,eq,${recipientEmail})~and(Sent At,gte,${windowStart})`,
|
||||
sort: '-CreatedAt',
|
||||
limit: 1
|
||||
};
|
||||
|
||||
const response = await this.getAll(this.tableIds.emails, params);
|
||||
return response.list && response.list.length > 0 ? response.list[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error checking recent email send:', error);
|
||||
return null; // On error, allow the send (fallback to in-memory limiter)
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(filters = {}) {
|
||||
try {
|
||||
let whereClause = '';
|
||||
|
||||
Reference in New Issue
Block a user