#!/usr/bin/env node /** * Ente OTP Email Monitor * * Monitors Museum server logs for OTP generation events and sends * verification emails using Cloudron's email addon. */ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const nodemailer = require('nodemailer'); // Configuration const CONFIG = { LOG_FILE: '/app/data/logs/museum.log', EMAIL_TEMPLATES_DIR: '/app/data/ente/server/mail-templates', FROM_EMAIL: `noreply@${process.env.CLOUDRON_EMAIL_DOMAIN || 'localhost'}`, FROM_NAME: 'Ente Photos', SMTP: { host: process.env.CLOUDRON_EMAIL_SMTP_SERVER, port: parseInt(process.env.CLOUDRON_EMAIL_SMTP_PORT) || 587, secure: false, // STARTTLS disabled on this port auth: false // Internal mail server } }; // Logging utility class Logger { static log(level, message, data = null) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [OTP-EMAIL-${level}] ${message}`; console.log(logMessage); if (data) { console.log(JSON.stringify(data, null, 2)); } // Also write to file try { fs.appendFileSync('/app/data/logs/otp-email.log', logMessage + '\n'); } catch (err) { console.error('Failed to write to log file:', err.message); } } static info(message, data) { this.log('INFO', message, data); } static warn(message, data) { this.log('WARN', message, data); } static error(message, data) { this.log('ERROR', message, data); } } // Email template handler class EmailTemplate { constructor(templateDir) { this.templateDir = templateDir; this.templates = new Map(); this.loadTemplates(); } loadTemplates() { try { const ottTemplate = fs.readFileSync(path.join(this.templateDir, 'ott.html'), 'utf8'); const changeEmailTemplate = fs.readFileSync(path.join(this.templateDir, 'ott_change_email.html'), 'utf8'); this.templates.set('ott', ottTemplate); this.templates.set('ott_change_email', changeEmailTemplate); Logger.info('Email templates loaded successfully'); } catch (err) { Logger.error('Failed to load email templates:', err.message); throw err; } } render(templateName, variables) { const template = this.templates.get(templateName); if (!template) { throw new Error(`Template ${templateName} not found`); } let html = template; // Replace template variables for (const [key, value] of Object.entries(variables)) { const placeholder = `{{.${key}}}`; html = html.replace(new RegExp(placeholder, 'g'), value); } return html; } } // Email sender using Cloudron email addon class EmailSender { constructor(config) { this.config = config; this.transporter = null; this.initializeTransporter(); } initializeTransporter() { try { this.transporter = nodemailer.createTransport({ host: this.config.SMTP.host, port: this.config.SMTP.port, secure: this.config.SMTP.secure, // No auth needed for internal Cloudron mail server tls: { rejectUnauthorized: false // Accept self-signed certificates } }); Logger.info('Email transporter initialized', { host: this.config.SMTP.host, port: this.config.SMTP.port }); } catch (err) { Logger.error('Failed to initialize email transporter:', err.message); throw err; } } async sendEmail(to, subject, html) { try { const mailOptions = { from: `${this.config.FROM_NAME} <${this.config.FROM_EMAIL}>`, to: to, subject: subject, html: html }; const result = await this.transporter.sendMail(mailOptions); Logger.info('Email sent successfully', { to: to, subject: subject, messageId: result.messageId }); return result; } catch (err) { Logger.error('Failed to send email:', { error: err.message, to: to, subject: subject }); throw err; } } } // Log monitor for OTP events class LogMonitor { constructor(logFile, emailSender, emailTemplate) { this.logFile = logFile; this.emailSender = emailSender; this.emailTemplate = emailTemplate; this.tail = null; this.processedOTPs = new Set(); // Prevent duplicate sends } start() { Logger.info('Starting log monitor', { logFile: this.logFile }); // Use tail -F to follow log file this.tail = spawn('tail', ['-F', this.logFile]); this.tail.stdout.on('data', (data) => { const lines = data.toString().split('\n'); for (const line of lines) { if (line.trim()) { this.processLogLine(line); } } }); this.tail.stderr.on('data', (data) => { Logger.warn('Tail stderr:', data.toString()); }); this.tail.on('close', (code) => { Logger.warn('Tail process closed', { code }); // Restart after 5 seconds setTimeout(() => this.start(), 5000); }); this.tail.on('error', (err) => { Logger.error('Tail process error:', err.message); setTimeout(() => this.start(), 5000); }); } stop() { if (this.tail) { this.tail.kill(); this.tail = null; Logger.info('Log monitor stopped'); } } processLogLine(line) { try { // Look for OTP-related log entries // Museum server logs OTP generation in various formats const patterns = [ // Pattern 1: Museum skipping email - MOST IMPORTANT (matches "Skipping sending email to andreas@due.ren: Verification code: 192305") /Skipping sending email to\s+([^\s:]+):\s*Verification code:\s*(\d{6})/i, // Pattern 2: Direct OTP generation logs /sendOTT.*email[:\s]+([^\s]+).*code[:\s]+(\d{6})/i, // Pattern 3: User registration/login with OTP /generateOTT.*user[:\s]+([^\s]+).*verification.*code[:\s]+(\d{6})/i, // Pattern 4: Email change OTP /changeEmail.*email[:\s]+([^\s]+).*otp[:\s]+(\d{6})/i, // Pattern 5: Generic OTP patterns in logs /ott.*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}).*(\d{6})/i ]; for (const pattern of patterns) { const match = line.match(pattern); if (match) { const email = match[1]; const otpCode = match[2]; // Create unique identifier to prevent duplicates const otpId = `${email}:${otpCode}:${Date.now().toString().slice(-6)}`; if (!this.processedOTPs.has(otpId)) { this.processedOTPs.add(otpId); this.sendOTPEmail(email, otpCode, line); // Clean up old OTPs (keep last 100) if (this.processedOTPs.size > 100) { const oldOTPs = Array.from(this.processedOTPs).slice(0, 50); oldOTPs.forEach(otp => this.processedOTPs.delete(otp)); } } break; } } } catch (err) { Logger.error('Error processing log line:', { error: err.message, line: line.substring(0, 100) }); } } async sendOTPEmail(email, otpCode, logLine) { try { Logger.info('Processing OTP email request', { email: email, otpCode: otpCode.substring(0, 2) + '****', // Partial OTP for logging source: logLine.substring(0, 100) }); // Determine template type based on context let templateName = 'ott'; let subject = 'Ente - Verification Code'; if (logLine.toLowerCase().includes('change') || logLine.toLowerCase().includes('email')) { templateName = 'ott_change_email'; subject = 'Ente - Email Change Verification'; } // Render email template const html = this.emailTemplate.render(templateName, { VerificationCode: otpCode }); // Send email await this.emailSender.sendEmail(email, subject, html); Logger.info('OTP email sent successfully', { email: email, template: templateName }); } catch (err) { Logger.error('Failed to send OTP email:', { error: err.message, email: email, otpCode: otpCode.substring(0, 2) + '****' }); } } } // Health check endpoint class HealthServer { constructor(port = 8081) { this.port = port; this.server = null; } start() { const http = require('http'); this.server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy', service: 'ente-otp-email-monitor', timestamp: new Date().toISOString(), processedOTPs: monitor ? monitor.processedOTPs.size : 0 })); } else { res.writeHead(404); res.end('Not Found'); } }); this.server.listen(this.port, () => { Logger.info(`Health server listening on port ${this.port}`); }); } stop() { if (this.server) { this.server.close(); Logger.info('Health server stopped'); } } } // Main application let monitor = null; let healthServer = null; async function main() { try { Logger.info('Starting Ente OTP Email Monitor'); // Validate environment if (!process.env.CLOUDRON_EMAIL_SMTP_SERVER) { throw new Error('CLOUDRON_EMAIL_SMTP_SERVER not found. Email addon may not be configured.'); } // Initialize components const emailTemplate = new EmailTemplate(CONFIG.EMAIL_TEMPLATES_DIR); const emailSender = new EmailSender(CONFIG); // Test email connectivity Logger.info('Testing email connectivity...'); await emailSender.transporter.verify(); Logger.info('Email connectivity verified'); // Start log monitor monitor = new LogMonitor(CONFIG.LOG_FILE, emailSender, emailTemplate); monitor.start(); // Start health server healthServer = new HealthServer(); healthServer.start(); Logger.info('Ente OTP Email Monitor started successfully'); // Handle graceful shutdown process.on('SIGINT', () => { Logger.info('Received SIGINT, shutting down gracefully...'); if (monitor) monitor.stop(); if (healthServer) healthServer.stop(); process.exit(0); }); process.on('SIGTERM', () => { Logger.info('Received SIGTERM, shutting down gracefully...'); if (monitor) monitor.stop(); if (healthServer) healthServer.stop(); process.exit(0); }); } catch (err) { Logger.error('Failed to start OTP Email Monitor:', err.message); process.exit(1); } } // Start the application if (require.main === module) { main(); } module.exports = { LogMonitor, EmailSender, EmailTemplate, Logger };