2025-10-31 20:09:31 +01:00
|
|
|
|
import nodemailer from 'nodemailer';
|
|
|
|
|
|
import handlebars from 'handlebars';
|
|
|
|
|
|
import fs from 'fs/promises';
|
|
|
|
|
|
import path from 'path';
|
|
|
|
|
|
import { fileURLToPath } from 'url';
|
|
|
|
|
|
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
|
|
|
2025-10-31 20:53:04 +01:00
|
|
|
|
// Helper function to format currency with commas
|
|
|
|
|
|
function formatCurrency(amount) {
|
|
|
|
|
|
const num = typeof amount === 'number' ? (amount / 100).toFixed(2) : parseFloat(amount).toFixed(2);
|
|
|
|
|
|
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-31 20:09:31 +01:00
|
|
|
|
// Create transporter with connection pooling
|
|
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
|
|
|
|
host: process.env.SMTP_HOST || 'mail.puffinoffset.com',
|
|
|
|
|
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
|
|
|
|
|
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
|
|
|
|
|
auth: {
|
|
|
|
|
|
user: process.env.SMTP_USER || 'noreply@puffinoffset.com',
|
|
|
|
|
|
pass: process.env.SMTP_PASSWORD
|
|
|
|
|
|
},
|
|
|
|
|
|
pool: true,
|
|
|
|
|
|
maxConnections: 5,
|
|
|
|
|
|
maxMessages: 100,
|
|
|
|
|
|
rateDelta: 1000,
|
|
|
|
|
|
rateLimit: 10
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Verify connection configuration on startup
|
|
|
|
|
|
export async function verifyConnection() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await transporter.verify();
|
|
|
|
|
|
console.log('✅ SMTP server is ready to send emails');
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('❌ SMTP connection failed:', error.message);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load and compile template
|
|
|
|
|
|
async function loadTemplate(templateName) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const templatePath = path.join(__dirname, '..', 'templates', `${templateName}.hbs`);
|
|
|
|
|
|
const templateSource = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
|
|
return handlebars.compile(templateSource);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`Failed to load template ${templateName}:`, error.message);
|
|
|
|
|
|
throw new Error(`Template ${templateName} not found`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send email using template
|
|
|
|
|
|
export async function sendTemplateEmail(to, subject, templateName, data) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Load and compile template
|
|
|
|
|
|
const template = await loadTemplate(templateName);
|
|
|
|
|
|
const html = template(data);
|
|
|
|
|
|
|
|
|
|
|
|
// Create plain text version (strip HTML tags)
|
|
|
|
|
|
const text = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
|
|
|
|
|
|
// Send email
|
|
|
|
|
|
const info = await transporter.sendMail({
|
|
|
|
|
|
from: `"${process.env.SMTP_FROM_NAME || 'Puffin Offset'}" <${process.env.SMTP_FROM_EMAIL || 'noreply@puffinoffset.com'}>`,
|
|
|
|
|
|
to,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
text,
|
|
|
|
|
|
html
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`✉️ Email sent: ${info.messageId} to ${to}`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
messageId: info.messageId,
|
|
|
|
|
|
accepted: info.accepted,
|
|
|
|
|
|
rejected: info.rejected
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('❌ Failed to send email:', error.message);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send receipt email
|
|
|
|
|
|
export async function sendReceiptEmail(customerEmail, orderDetails) {
|
|
|
|
|
|
const subject = `Order Confirmation - ${orderDetails.tons} tons CO₂ Offset`;
|
|
|
|
|
|
|
|
|
|
|
|
// Prepare template data
|
|
|
|
|
|
const templateData = {
|
|
|
|
|
|
tons: orderDetails.tons,
|
|
|
|
|
|
portfolioId: orderDetails.portfolioId,
|
2025-10-31 20:53:04 +01:00
|
|
|
|
baseAmount: formatCurrency(orderDetails.baseAmount),
|
|
|
|
|
|
processingFee: formatCurrency(orderDetails.processingFee),
|
|
|
|
|
|
totalAmount: formatCurrency(orderDetails.totalAmount),
|
2025-10-31 20:09:31 +01:00
|
|
|
|
orderId: orderDetails.orderId,
|
|
|
|
|
|
stripeSessionId: orderDetails.stripeSessionId,
|
|
|
|
|
|
date: new Date().toLocaleDateString('en-US', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'long',
|
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
|
})
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Add portfolio projects if available
|
|
|
|
|
|
if (orderDetails.projects && orderDetails.projects.length > 0) {
|
|
|
|
|
|
templateData.projects = orderDetails.projects;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add carbon comparisons if available
|
|
|
|
|
|
if (orderDetails.comparisons && orderDetails.comparisons.length > 0) {
|
|
|
|
|
|
templateData.comparisons = orderDetails.comparisons;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await sendTemplateEmail(
|
|
|
|
|
|
customerEmail,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
'receipt',
|
|
|
|
|
|
templateData
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send contact form email
|
|
|
|
|
|
export async function sendContactEmail(contactData) {
|
|
|
|
|
|
const subject = `Contact Form: ${contactData.name}`;
|
|
|
|
|
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@puffinoffset.com';
|
|
|
|
|
|
|
|
|
|
|
|
return await sendTemplateEmail(
|
|
|
|
|
|
adminEmail,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
'contact',
|
|
|
|
|
|
{
|
|
|
|
|
|
name: contactData.name,
|
|
|
|
|
|
email: contactData.email,
|
|
|
|
|
|
phone: contactData.phone || 'Not provided',
|
|
|
|
|
|
company: contactData.company || 'Not provided',
|
|
|
|
|
|
message: contactData.message,
|
|
|
|
|
|
timestamp: new Date().toLocaleString('en-US', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'long',
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Send admin notification for new order
|
|
|
|
|
|
export async function sendAdminNotification(orderDetails, customerEmail) {
|
|
|
|
|
|
const subject = `New Order: ${orderDetails.tons} tons CO₂ - $${(orderDetails.totalAmount / 100).toFixed(2)}`;
|
|
|
|
|
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@puffinoffset.com';
|
|
|
|
|
|
|
|
|
|
|
|
// Check if admin notifications are enabled
|
|
|
|
|
|
if (process.env.ADMIN_NOTIFY_ON_ORDER === 'false') {
|
|
|
|
|
|
console.log('ℹ️ Admin notifications disabled, skipping');
|
|
|
|
|
|
return { success: true, skipped: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await sendTemplateEmail(
|
|
|
|
|
|
adminEmail,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
'admin-notification',
|
|
|
|
|
|
{
|
|
|
|
|
|
tons: orderDetails.tons,
|
|
|
|
|
|
portfolioId: orderDetails.portfolioId,
|
|
|
|
|
|
totalAmount: (orderDetails.totalAmount / 100).toFixed(2),
|
|
|
|
|
|
orderId: orderDetails.orderId,
|
|
|
|
|
|
customerEmail,
|
|
|
|
|
|
timestamp: new Date().toLocaleString('en-US', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'long',
|
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Close transporter (for graceful shutdown)
|
|
|
|
|
|
export function closeTransporter() {
|
|
|
|
|
|
transporter.close();
|
|
|
|
|
|
console.log('📪 Email transporter closed');
|
|
|
|
|
|
}
|