/** * Email service for sending emails via SMTP * Supports hybrid configuration (DB + .env fallback) */ import nodemailer, { Transporter } from 'nodemailer'; import { SMTPConfig } from './types/smtp'; import { settingsDb } from './settings-db'; import { renderWelcomeEmail, renderPasswordResetEmail, renderMqttCredentialsEmail, WelcomeEmailData, PasswordResetEmailData, MqttCredentialsEmailData, } from './email-renderer'; export class EmailService { /** * Cached SMTP transporter instance. * Set to null initially and reused for subsequent emails to avoid reconnecting. * Call resetTransporter() when SMTP configuration changes to invalidate cache. */ private transporter: Transporter | null = null; /** * Get SMTP configuration (DB first, then .env fallback) */ private async getConfig(): Promise { // Try database first const dbConfig = settingsDb.getSMTPConfig(); if (dbConfig) { console.log('[EmailService] Using SMTP config from database'); return dbConfig; } // Fallback to environment variables console.log('[EmailService] Using SMTP config from environment'); const envConfig: SMTPConfig = { host: process.env.SMTP_HOST || '', port: parseInt(process.env.SMTP_PORT || '587', 10), secure: process.env.SMTP_SECURE === 'true', auth: { user: process.env.SMTP_USER || '', pass: process.env.SMTP_PASS || '', }, from: { email: process.env.SMTP_FROM_EMAIL || '', name: process.env.SMTP_FROM_NAME || 'Location Tracker', }, timeout: 10000, }; // Validate env config if (!envConfig.host || !envConfig.auth.user || !envConfig.auth.pass) { throw new Error('SMTP configuration is incomplete. Please configure SMTP settings in admin panel or .env file.'); } return envConfig; } /** * Create and configure nodemailer transporter */ private async getTransporter(): Promise { if (this.transporter) { return this.transporter; } const config = await this.getConfig(); this.transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: { user: config.auth.user, pass: config.auth.pass, }, connectionTimeout: config.timeout || 10000, }); return this.transporter; } /** * Send an email */ private async sendEmail( to: string, subject: string, html: string ): Promise { try { const config = await this.getConfig(); const transporter = await this.getTransporter(); const info = await transporter.sendMail({ from: `"${config.from.name}" <${config.from.email}>`, to, subject, html, replyTo: config.replyTo, }); console.log('[EmailService] Email sent:', { messageId: info.messageId, to, subject, }); } catch (error) { console.error('[EmailService] Failed to send email:', error); throw new Error(`Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Send welcome email to new user */ async sendWelcomeEmail(data: WelcomeEmailData & { email: string }): Promise { const html = await renderWelcomeEmail({ username: data.username, loginUrl: data.loginUrl, temporaryPassword: data.temporaryPassword, }); await this.sendEmail( data.email, 'Welcome to Location Tracker', html ); } /** * Send password reset email */ async sendPasswordResetEmail(data: PasswordResetEmailData & { email: string }): Promise { const html = await renderPasswordResetEmail({ username: data.username, resetUrl: data.resetUrl, expiresIn: data.expiresIn || '1 hour', }); await this.sendEmail( data.email, 'Password Reset Request - Location Tracker', html ); } /** * Send MQTT credentials email */ async sendMqttCredentialsEmail(data: MqttCredentialsEmailData & { email: string }): Promise { const html = await renderMqttCredentialsEmail({ deviceName: data.deviceName, deviceId: data.deviceId, mqttUsername: data.mqttUsername, mqttPassword: data.mqttPassword, brokerUrl: data.brokerUrl, brokerHost: data.brokerHost, brokerPort: data.brokerPort, }); await this.sendEmail( data.email, `MQTT Credentials - ${data.deviceName}`, html ); } /** * Test SMTP connection * @throws Error with detailed message if connection fails */ async testConnection(config?: SMTPConfig): Promise { try { let transporter: Transporter; if (config) { // Test provided config transporter = nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: { user: config.auth.user, pass: config.auth.pass, }, connectionTimeout: config.timeout || 10000, }); } else { // Test current config transporter = await this.getTransporter(); } await transporter.verify(); console.log('[EmailService] SMTP connection test successful'); return true; } catch (error: any) { console.error('[EmailService] SMTP connection test failed:', error); // Provide more helpful error messages if (error.code === 'EAUTH') { throw new Error( 'Authentication failed. For Gmail, use an App Password (not your regular password). ' + 'Enable 2FA and generate an App Password at: https://myaccount.google.com/apppasswords' ); } else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNECTION') { throw new Error('Connection timeout. Check your host, port, and firewall settings.'); } else if (error.code === 'ESOCKET') { throw new Error('Connection failed. Verify your SMTP host and port are correct.'); } else { throw new Error(error.message || 'SMTP connection test failed'); } } } /** * Reset the cached transporter (call when SMTP config changes) */ resetTransporter(): void { this.transporter = null; } } // Export singleton instance export const emailService = new EmailService();