Tonne of updates to influence, the configs, update the homepage, and generally just did more bug testing with Influence

This commit is contained in:
2025-09-21 13:36:48 -06:00
parent b6960e45e8
commit feb2e72c9a
29 changed files with 2033 additions and 77 deletions

View File

@@ -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();

View 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();

View File

@@ -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 = '';