feat: self-hosted postfix
This commit is contained in:
213
utils/mailer.ts
Normal file
213
utils/mailer.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user