Files
newsletter-hackernews/utils/mailer.ts

214 lines
5.5 KiB
TypeScript

import nodemailer from 'nodemailer';
import prisma from '@prisma/prisma';
interface EmailTemplate {
subject: string;
template: JSX.Element;
}
interface SendResult {
success: boolean;
messageId?: string;
error?: string;
}
// Create nodemailer transporter for Postfix container
const createTransporter = () => {
// Default to 'postfix' for Docker networking (service name in docker-compose)
// Override with EMAIL_HOST for different setups
const host = process.env.EMAIL_HOST || 'postfix';
const port = parseInt(process.env.EMAIL_PORT || '25', 10);
return nodemailer.createTransport({
host,
port,
secure: false, // true for 465, false for other ports
tls: {
rejectUnauthorized: false
},
// Connection pooling for better performance
pool: true,
maxConnections: 5,
maxMessages: 100
});
};
// Render React component to HTML string using dynamic import
const renderTemplate = async (template: JSX.Element): Promise<string> => {
// Dynamic import to avoid Next.js bundling issues with react-dom/server
const { renderToStaticMarkup } = await import('react-dom/server');
const html = renderToStaticMarkup(template);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
${html}
</body>
</html>`;
};
// Generate plain text version from HTML (basic extraction)
const htmlToPlainText = (html: string): string => {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
};
// Log email send attempt to database
const logEmailSend = async (
recipient: string,
subject: string,
status: 'sent' | 'failed',
messageId?: string,
errorMessage?: string
) => {
try {
await prisma.emailLog.create({
data: {
recipient,
subject,
status,
messageId,
errorMessage
}
});
} catch (error) {
console.error('Failed to log email send:', error);
}
};
// Send a single email
const sendSingleEmail = async (
transporter: nodemailer.Transporter,
recipient: string,
subject: string,
htmlContent: string,
textContent: string
): Promise<SendResult> => {
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
const fromName = process.env.NEXT_PUBLIC_BRAND_NAME || 'HackerNews Newsletter';
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromAddress}>`,
to: recipient,
subject,
text: textContent,
html: htmlContent,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`,
'X-Newsletter-ID': `hackernews-${Date.now()}`
}
});
await logEmailSend(recipient, subject, 'sent', info.messageId);
return {
success: true,
messageId: info.messageId
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await logEmailSend(recipient, subject, 'failed', undefined, errorMessage);
return {
success: false,
error: errorMessage
};
}
};
// Main sender function - maintains same API as Resend version
export async function sender(
recipients: string[],
{ subject, template }: EmailTemplate
): Promise<boolean> {
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
if (!fromAddress) {
throw new Error('EMAIL_FROM or RESEND_FROM environment variable is not set');
}
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const transporter = createTransporter();
// Render template to HTML
const htmlContent = await renderTemplate(template);
const textContent = htmlToPlainText(htmlContent);
// Add small delay between sends to avoid overwhelming mail server
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
let successCount = 0;
let failCount = 0;
const errors: Array<{ email: string; error: string }> = [];
for (const recipient of recipients) {
const result = await sendSingleEmail(
transporter,
recipient,
subject,
htmlContent,
textContent
);
if (result.success) {
successCount++;
} else {
failCount++;
errors.push({ email: recipient, error: result.error || 'Unknown error' });
}
// Add 100ms delay between sends
if (recipients.length > 1) {
await delay(100);
}
}
// Close the transporter pool
transporter.close();
if (errors.length > 0) {
console.error('Email send errors:', errors);
}
console.info(
`${subject} email: ${successCount} sent, ${failCount} failed out of ${recipients.length} recipients`
);
// Return true if at least one email was sent successfully
return successCount > 0;
}
// Verify transporter connection (useful for health checks)
export async function verifyMailer(): Promise<boolean> {
const transporter = createTransporter();
try {
await transporter.verify();
console.log('Email server connection verified');
transporter.close();
return true;
} catch (error) {
console.error('Email server connection failed:', error);
transporter.close();
return false;
}
}