419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import nodemailer from 'nodemailer';
|
|
import handlebars from 'handlebars';
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import type { SMTPConfig } from '~/utils/types';
|
|
|
|
export interface EmailData {
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
text?: string;
|
|
}
|
|
|
|
export interface WelcomeEmailData {
|
|
firstName: string;
|
|
lastName: string;
|
|
verificationLink: string;
|
|
memberId: string;
|
|
logoUrl?: string;
|
|
}
|
|
|
|
export interface VerificationEmailData {
|
|
firstName: string;
|
|
verificationLink: string;
|
|
logoUrl?: string;
|
|
}
|
|
|
|
export interface PasswordResetEmailData {
|
|
firstName: string;
|
|
resetLink: string;
|
|
logoUrl?: string;
|
|
}
|
|
|
|
export interface DuesReminderEmailData {
|
|
firstName: string;
|
|
lastName: string;
|
|
amount: number;
|
|
dueDate: string;
|
|
iban?: string;
|
|
accountHolder?: string;
|
|
logoUrl?: string;
|
|
}
|
|
|
|
export class EmailService {
|
|
private transporter: nodemailer.Transporter | null = null;
|
|
private templates: Map<string, handlebars.TemplateDelegate> = new Map();
|
|
|
|
constructor(private config: SMTPConfig) {
|
|
this.initializeTransporter();
|
|
this.preloadTemplates();
|
|
}
|
|
|
|
/**
|
|
* Initialize the nodemailer transporter
|
|
*/
|
|
private initializeTransporter(): void {
|
|
if (!this.config.host || !this.config.port) {
|
|
console.warn('[EmailService] SMTP configuration incomplete, emails will not be sent');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Determine security settings based on port
|
|
let useSecure = this.config.secure;
|
|
let requireTLS = false;
|
|
|
|
// Auto-configure based on standard ports if not explicitly set
|
|
if (this.config.port === 587) {
|
|
// Port 587 typically uses STARTTLS
|
|
useSecure = false;
|
|
requireTLS = true;
|
|
} else if (this.config.port === 465) {
|
|
// Port 465 typically uses SSL/TLS
|
|
useSecure = true;
|
|
requireTLS = false;
|
|
} else if (this.config.port === 25) {
|
|
// Port 25 typically unencrypted (not recommended)
|
|
useSecure = false;
|
|
requireTLS = false;
|
|
}
|
|
|
|
// Build transporter options
|
|
const transporterOptions: any = {
|
|
host: this.config.host,
|
|
port: this.config.port,
|
|
secure: useSecure,
|
|
// Increased timeout settings to handle slow servers
|
|
connectionTimeout: 60000, // 60 seconds
|
|
greetingTimeout: 60000,
|
|
socketTimeout: 60000,
|
|
// Pool configuration for better connection management
|
|
pool: false,
|
|
maxConnections: 1,
|
|
// Debug logging (can be enabled for troubleshooting)
|
|
logger: false,
|
|
debug: false
|
|
};
|
|
|
|
// Add requireTLS if needed (for STARTTLS)
|
|
if (requireTLS && !useSecure) {
|
|
transporterOptions.requireTLS = true;
|
|
transporterOptions.opportunisticTLS = true;
|
|
}
|
|
|
|
// Configure TLS options
|
|
transporterOptions.tls = {
|
|
rejectUnauthorized: false, // Accept self-signed certificates
|
|
// Allow various TLS versions for compatibility
|
|
minVersion: 'TLSv1',
|
|
// Don't specify ciphers to allow auto-negotiation
|
|
};
|
|
|
|
// Add authentication only if credentials are provided
|
|
if (this.config.username && this.config.password) {
|
|
transporterOptions.auth = {
|
|
user: this.config.username,
|
|
pass: this.config.password,
|
|
// Try different auth methods for compatibility
|
|
type: 'login' // Can be 'oauth2', 'login', or omitted for auto-detection
|
|
};
|
|
|
|
// For some servers, disabling STARTTLS can help
|
|
if (this.config.port === 587) {
|
|
transporterOptions.ignoreTLS = false;
|
|
transporterOptions.secure = false;
|
|
transporterOptions.requireTLS = true;
|
|
}
|
|
}
|
|
|
|
this.transporter = nodemailer.createTransport(transporterOptions);
|
|
|
|
console.log('[EmailService] ✅ SMTP transporter initialized with options:', {
|
|
host: this.config.host,
|
|
port: this.config.port,
|
|
secure: transporterOptions.secure,
|
|
requireTLS: transporterOptions.requireTLS,
|
|
auth: !!transporterOptions.auth
|
|
});
|
|
} catch (error) {
|
|
console.error('[EmailService] ❌ Failed to initialize SMTP transporter:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preload and compile email templates
|
|
*/
|
|
private preloadTemplates(): void {
|
|
const templateNames = ['welcome', 'verification', 'password-reset', 'dues-reminder', 'test'];
|
|
|
|
templateNames.forEach(templateName => {
|
|
try {
|
|
const templatePath = join(process.cwd(), 'server/templates', `${templateName}.hbs`);
|
|
const templateContent = readFileSync(templatePath, 'utf-8');
|
|
const compiledTemplate = handlebars.compile(templateContent);
|
|
this.templates.set(templateName, compiledTemplate);
|
|
console.log(`[EmailService] ✅ Template '${templateName}' loaded`);
|
|
} catch (error) {
|
|
console.warn(`[EmailService] ⚠️ Template '${templateName}' not found or failed to load:`, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a compiled template by name
|
|
*/
|
|
private getTemplate(templateName: string): handlebars.TemplateDelegate | null {
|
|
return this.templates.get(templateName) || null;
|
|
}
|
|
|
|
/**
|
|
* Send a generic email
|
|
*/
|
|
private async sendEmail(emailData: EmailData): Promise<void> {
|
|
if (!this.transporter) {
|
|
throw new Error('SMTP transporter not initialized');
|
|
}
|
|
|
|
const mailOptions = {
|
|
from: `${this.config.fromName} <${this.config.fromAddress}>`,
|
|
to: emailData.to,
|
|
subject: emailData.subject,
|
|
html: emailData.html,
|
|
text: emailData.text || undefined
|
|
};
|
|
|
|
try {
|
|
const info = await this.transporter.sendMail(mailOptions);
|
|
console.log(`[EmailService] ✅ Email sent successfully to ${emailData.to}:`, info.messageId);
|
|
} catch (error) {
|
|
console.error(`[EmailService] ❌ Failed to send email to ${emailData.to}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send welcome/verification email to new members
|
|
*/
|
|
async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise<void> {
|
|
const template = this.getTemplate('welcome');
|
|
if (!template) {
|
|
throw new Error('Welcome email template not found');
|
|
}
|
|
|
|
const templateData = {
|
|
...data,
|
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
|
};
|
|
|
|
const html = template(templateData);
|
|
|
|
await this.sendEmail({
|
|
to,
|
|
subject: 'Welcome to MonacoUSA - Please Verify Your Email',
|
|
html
|
|
});
|
|
|
|
console.log(`[EmailService] ✅ Welcome email sent to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Send email verification email
|
|
*/
|
|
async sendVerificationEmail(to: string, data: VerificationEmailData): Promise<void> {
|
|
const template = this.getTemplate('verification');
|
|
if (!template) {
|
|
throw new Error('Verification email template not found');
|
|
}
|
|
|
|
const templateData = {
|
|
...data,
|
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
|
};
|
|
|
|
const html = template(templateData);
|
|
|
|
await this.sendEmail({
|
|
to,
|
|
subject: 'Verify Your Email - MonacoUSA Portal',
|
|
html
|
|
});
|
|
|
|
console.log(`[EmailService] ✅ Verification email sent to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Send password reset email
|
|
*/
|
|
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
|
|
const template = this.getTemplate('password-reset');
|
|
if (!template) {
|
|
throw new Error('Password reset email template not found');
|
|
}
|
|
|
|
const templateData = {
|
|
...data,
|
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
|
};
|
|
|
|
const html = template(templateData);
|
|
|
|
await this.sendEmail({
|
|
to,
|
|
subject: 'Reset Your Password - MonacoUSA Portal',
|
|
html
|
|
});
|
|
|
|
console.log(`[EmailService] ✅ Password reset email sent to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Send membership dues reminder email
|
|
*/
|
|
async sendDuesReminderEmail(to: string, data: DuesReminderEmailData): Promise<void> {
|
|
const template = this.getTemplate('dues-reminder');
|
|
if (!template) {
|
|
throw new Error('Dues reminder email template not found');
|
|
}
|
|
|
|
const templateData = {
|
|
...data,
|
|
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
|
};
|
|
|
|
const html = template(templateData);
|
|
|
|
await this.sendEmail({
|
|
to,
|
|
subject: `MonacoUSA Membership Dues Reminder - €${data.amount} Due`,
|
|
html
|
|
});
|
|
|
|
console.log(`[EmailService] ✅ Dues reminder email sent to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Send test email to verify SMTP configuration
|
|
*/
|
|
async sendTestEmail(to: string): Promise<void> {
|
|
const template = this.getTemplate('test');
|
|
|
|
const templateData = {
|
|
testTime: new Date().toISOString(),
|
|
logoUrl: `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`,
|
|
smtpHost: this.config.host,
|
|
fromAddress: this.config.fromAddress
|
|
};
|
|
|
|
let html: string;
|
|
if (template) {
|
|
html = template(templateData);
|
|
} else {
|
|
// Fallback HTML if template is not available
|
|
html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>SMTP Test Email</title></head>
|
|
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
<div style="text-align: center; margin-bottom: 30px;">
|
|
<img src="${templateData.logoUrl}" alt="MonacoUSA" style="width: 100px;">
|
|
<h1 style="color: #a31515;">SMTP Test Email</h1>
|
|
</div>
|
|
|
|
<p>This is a test email from the MonacoUSA Portal email system.</p>
|
|
|
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
|
<p><strong>Test Details:</strong></p>
|
|
<ul>
|
|
<li>Sent at: ${templateData.testTime}</li>
|
|
<li>SMTP Host: ${templateData.smtpHost}</li>
|
|
<li>From Address: ${templateData.fromAddress}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p>If you received this email, your SMTP configuration is working correctly!</p>
|
|
|
|
<hr style="margin: 30px 0;">
|
|
<p style="color: #666; font-size: 14px; text-align: center;">
|
|
MonacoUSA Portal Email System
|
|
</p>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
await this.sendEmail({
|
|
to,
|
|
subject: 'SMTP Configuration Test - MonacoUSA Portal',
|
|
html,
|
|
text: `This is a test email from MonacoUSA Portal. Sent at: ${templateData.testTime}`
|
|
});
|
|
|
|
console.log(`[EmailService] ✅ Test email sent to ${to}`);
|
|
}
|
|
|
|
/**
|
|
* Verify SMTP connection
|
|
*/
|
|
async verifyConnection(): Promise<boolean> {
|
|
if (!this.transporter) {
|
|
throw new Error('SMTP transporter not initialized');
|
|
}
|
|
|
|
try {
|
|
await this.transporter.verify();
|
|
console.log('[EmailService] ✅ SMTP connection verified');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[EmailService] ❌ SMTP connection verification failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update SMTP configuration and reinitialize transporter
|
|
*/
|
|
updateConfig(newConfig: SMTPConfig): void {
|
|
this.config = newConfig;
|
|
this.initializeTransporter();
|
|
}
|
|
|
|
/**
|
|
* Close the transporter connection
|
|
*/
|
|
close(): void {
|
|
if (this.transporter) {
|
|
this.transporter.close();
|
|
this.transporter = null;
|
|
console.log('[EmailService] SMTP connection closed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton instance for reuse across the application
|
|
let emailServiceInstance: EmailService | null = null;
|
|
|
|
/**
|
|
* Get or create EmailService instance with current SMTP config
|
|
*/
|
|
export async function getEmailService(): Promise<EmailService> {
|
|
const { getSMTPConfig } = await import('./admin-config');
|
|
const config = getSMTPConfig();
|
|
|
|
if (!emailServiceInstance) {
|
|
emailServiceInstance = new EmailService(config);
|
|
} else {
|
|
// Update config in case it changed
|
|
emailServiceInstance.updateConfig(config);
|
|
}
|
|
|
|
return emailServiceInstance;
|
|
}
|
|
|
|
/**
|
|
* Create a new EmailService instance with custom config
|
|
*/
|
|
export function createEmailService(config: SMTPConfig): EmailService {
|
|
return new EmailService(config);
|
|
}
|