214 lines
5.5 KiB
TypeScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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;
|
|
}
|
|
}
|