puffin-app/server/utils/emailService.js
Matt 7bdd462be9
Some checks failed
Build and Push Docker Images / docker (push) Has been cancelled
Implement comprehensive email templates with SMTP integration
- Add beautiful HTML email templates for receipts, admin notifications, and contact forms
- Implement SMTP email service with Nodemailer and Handlebars templating
- Add carbon equivalency calculations with EPA/DEFRA/IMO 2024 conversion factors
- Add portfolio color palette system for project visualization
- Integrate Wren API portfolio fetching in webhook handler
- Add light mode enforcement for email client compatibility
- Include Puffin logo from MinIO S3 in all templates
- Add test email endpoint for template validation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 20:09:31 +01:00

189 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
// 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,
baseAmount: typeof orderDetails.baseAmount === 'number'
? (orderDetails.baseAmount / 100).toFixed(2)
: orderDetails.baseAmount,
processingFee: typeof orderDetails.processingFee === 'number'
? (orderDetails.processingFee / 100).toFixed(2)
: orderDetails.processingFee,
totalAmount: typeof orderDetails.totalAmount === 'number'
? (orderDetails.totalAmount / 100).toFixed(2)
: orderDetails.totalAmount,
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');
}