const nodemailer = require('nodemailer'); const Handlebars = require('handlebars'); const { convert: htmlToText } = require('html-to-text'); const fs = require('fs').promises; const path = require('path'); const logger = require('../utils/logger.util'); /** * Smart port detection for ProtonBridge * Production uses port 1026, development uses 1025 */ const getSmtpPort = () => { // Respect SMTP_PORT from .env (highest priority) if (process.env.SMTP_PORT) { const port = parseInt(process.env.SMTP_PORT); logger.info(`[EmailService] Using SMTP_PORT from .env: ${port}`); return port; } // Allow manual override (fallback) if (process.env.SMTP_PORT_OVERRIDE) { return parseInt(process.env.SMTP_PORT_OVERRIDE); } // Default fallback return 587; }; class EmailService { constructor() { this.templatesPath = path.join(__dirname, '../../email-templates'); this.templateCache = new Map(); this.transporter = null; this.isInitialized = false; // Initialize email service this.initialize(); } /** * Initialize Nodemailer transporter with ProtonBridge */ initialize() { // Check if email is enabled if (process.env.EMAIL_ENABLED !== 'true') { logger.info('[EmailService] Email service disabled (EMAIL_ENABLED != true)'); this.isInitialized = false; return; } if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { logger.warn('[EmailService] Email configuration incomplete - email sending disabled'); logger.warn('[EmailService] Required: SMTP_HOST, SMTP_USER, SMTP_PASS'); this.isInitialized = false; return; } const smtpPort = getSmtpPort(); try { // Create Nodemailer transporter this.transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, // 127.0.0.1 for ProtonBridge port: smtpPort, // 1026 (prod) or 1025 (dev) secure: process.env.SMTP_SECURE === 'true', // false for localhost auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, tls: { rejectUnauthorized: false, // localhost is secure minVersion: 'TLSv1.2', }, pool: true, // Use connection pooling maxConnections: 5, // Max concurrent connections maxMessages: 100, // Max messages per connection rateLimit: 10 // 10 messages per second max }); this.isInitialized = true; logger.info('[EmailService] Email service initialized successfully', { host: process.env.SMTP_HOST, port: smtpPort, user: process.env.SMTP_USER, provider: process.env.EMAIL_PROVIDER || 'smtp' }); // Verify SMTP connection this.verifyConnection(); } catch (error) { logger.error('[EmailService] Failed to initialize email service:', error); this.isInitialized = false; } } /** * Verify SMTP connection (async, doesn't block initialization) */ async verifyConnection() { try { await this.transporter.verify(); logger.info('[EmailService] SMTP connection verified successfully'); } catch (error) { logger.error('[EmailService] SMTP connection verification failed:', error); logger.error('[EmailService] Please check ProtonBridge is running and credentials are correct'); } } /** * Check if email service is available */ async checkConnection() { if (!this.isInitialized || !this.transporter) { return false; } try { await this.transporter.verify(); return true; } catch (error) { return false; } } /** * Load and compile template */ async loadTemplate(templateName) { if (this.templateCache.has(templateName)) { return this.templateCache.get(templateName); } const templatePath = path.join(this.templatesPath, `${templateName}.html`); const templateContent = await fs.readFile(templatePath, 'utf8'); const compiled = Handlebars.compile(templateContent); this.templateCache.set(templateName, compiled); return compiled; } /** * Render email from templates */ async renderEmail(tier, variables) { try { // Load base template const baseTemplate = await this.loadTemplate('base-template'); // Load content module for tier const contentTemplates = { 'research': 'research-updates-content', 'implementation': 'implementation-notes-content', 'governance': 'governance-discussions-content', 'project-updates': 'project-updates-content' }; const contentTemplateName = contentTemplates[tier]; if (!contentTemplateName) { throw new Error(`Unknown newsletter tier: ${tier}`); } const contentTemplate = await this.loadTemplate(contentTemplateName); // Render content module const renderedContent = contentTemplate(variables); // Render final email const finalHtml = baseTemplate({ ...variables, content_body: renderedContent }); // Generate plain text const plainText = htmlToText(finalHtml, { wordwrap: 80, ignoreImages: true, preserveNewlines: true }); return { html: finalHtml, text: plainText }; } catch (error) { logger.error('[EmailService] Template rendering error:', error); throw error; } } /** * Send email via SMTP (ProtonBridge or other) */ async send({ to, subject, html, text, from = null }) { if (!this.isInitialized || !this.transporter) { logger.warn('[EmailService] Email sending skipped - service not initialized'); return { success: false, error: 'Email service not configured' }; } const fromAddress = from || process.env.EMAIL_FROM || process.env.SMTP_USER || 'noreply@agenticgovernance.digital'; try { const info = await this.transporter.sendMail({ from: fromAddress, to, subject, html, text }); logger.info('[EmailService] Email sent successfully', { to, subject, messageId: info.messageId }); return { success: true, messageId: info.messageId }; } catch (error) { logger.error('[EmailService] Send error:', error); return { success: false, error: error.message }; } } /** * Send newsletter to subscribers */ async sendNewsletter({ tier, subject, previewText, variables, testMode = false, testEmail = null }) { const NewsletterSubscription = require('../models/NewsletterSubscription.model'); try { // Get subscribers for tier let subscribers; if (testMode) { subscribers = [{ email: testEmail, name: 'Test User', verification_token: 'test-token' }]; } else { subscribers = await NewsletterSubscription.findByInterest(tier, { verified: true, active: true }); } if (subscribers.length === 0) { return { success: true, message: 'No subscribers found for tier', sent: 0, failed: 0 }; } // Render base templates const results = { sent: 0, failed: 0, errors: [] }; // Send to each subscriber for (const subscriber of subscribers) { try { // Generate unsubscribe/preferences links const unsubscribeLink = `${process.env.NEWSLETTER_UNSUBSCRIBE_BASE_URL}?token=${subscriber.verification_token}`; const preferencesLink = `${process.env.NEWSLETTER_PREFERENCES_BASE_URL}?token=${subscriber.verification_token}`; // Render email with subscriber-specific data const { html, text } = await this.renderEmail(tier, { name: subscriber.name || 'there', tier_name: this.getTierDisplayName(tier), unsubscribe_link: unsubscribeLink, preferences_link: preferencesLink, ...variables }); // Send const result = await this.send({ to: subscriber.email, subject, html, text }); if (result.success) { results.sent++; } else { results.failed++; results.errors.push({ email: subscriber.email, error: result.error }); } } catch (error) { results.failed++; results.errors.push({ email: subscriber.email, error: error.message }); logger.error('[EmailService] Failed to send to subscriber:', { email: subscriber.email, error }); } } return { success: true, message: `Newsletter sent: ${results.sent} successful, ${results.failed} failed`, sent: results.sent, failed: results.failed, errors: results.errors.length > 0 ? results.errors : undefined }; } catch (error) { logger.error('[EmailService] Newsletter send error:', error); throw error; } } getTierDisplayName(tier) { const names = { 'research': 'Research Updates', 'implementation': 'Implementation Notes', 'governance': 'Governance Discussions', 'project-updates': 'Project Updates' }; return names[tier] || tier; } } module.exports = new EmailService();