diff --git a/CloudronManifest.json b/CloudronManifest.json index b908166..49c68b0 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -7,13 +7,14 @@ "contactEmail": "contact@ente.io", "tagline": "Open Source End-to-End Encrypted Photos & Authentication", "upstreamVersion": "1.0.0", - "version": "1.0.1", - "healthCheckPath": "/health", + "version": "0.1.62", + "healthCheckPath": "/ping", "httpPort": 3080, "memoryLimit": 1073741824, "addons": { "localstorage": {}, "postgresql": {}, + "email": {}, "sendmail": { "supportsDisplayName": true } diff --git a/otp-email-monitor.js b/otp-email-monitor.js new file mode 100644 index 0000000..4928151 --- /dev/null +++ b/otp-email-monitor.js @@ -0,0 +1,389 @@ +#!/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 +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..86ff8ec --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ente-otp-email-monitor", + "version": "1.0.0", + "description": "OTP email monitoring service for Ente Cloudron app", + "main": "otp-email-monitor.js", + "scripts": { + "start": "node otp-email-monitor.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "nodemailer": "^6.9.0" + }, + "keywords": [ + "ente", + "otp", + "email", + "cloudron", + "monitoring" + ], + "author": "Ente Cloudron Integration", + "license": "Apache-2.0" +} \ No newline at end of file