Explorar el Código

feat: add markdown-to-HTML rendering for email notifications #31

- Add Gogs API integration for markdown rendering using /api/v1/markdown endpoint
- Implement renderMarkdown() method with repository context support for GFM mode
- Add HTTPS request handler using Node.js built-in https module (zero dependencies)
- Enhanced HTML email templates with improved CSS for code, tables, blockquotes
- Fallback to plain text HTML if Gogs API is unavailable or errors occur
- Uses GOGS_SERVER_URL and GOGS_ACCESS_TOKEN from .env configuration

Email notifications now render markdown content (comments, issue descriptions)
as properly formatted HTML instead of raw markdown text.
Claude hace 11 meses
padre
commit
e344de4
Se han modificado 1 ficheros con 576 adiciones y 0 borrados
  1. 576 0
      src/emailNotifier.js

+ 576 - 0
src/emailNotifier.js

@@ -0,0 +1,576 @@
+/**
+ * Email Notifier - Callback module for sending email notifications
+ * Follows the same pattern as the Claude agent callback
+ */
+import fs from 'fs';
+import path from 'path';
+import https from 'https';
+import { fileURLToPath } from 'url';
+import logger from './logger.js';
+import EmailSender from './emailSender.js';
+import config from './config.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export class EmailNotifier {
+  constructor() {
+    this.sender = null;
+    this.rules = [];
+    this.userEmails = new Map(); // Cache for username -> email mapping
+    this.loadConfig();
+  }
+
+  /**
+   * Load SMTP configuration and email rules
+   */
+  loadConfig() {
+    // Load SMTP configuration
+    const smtpConfigPath = path.join(process.cwd(), '.smtp-config.json');
+
+    if (!fs.existsSync(smtpConfigPath)) {
+      logger.error('SMTP configuration not found at .smtp-config.json');
+      logger.info('Email notifications will be disabled');
+      return;
+    }
+
+    try {
+      const smtpConfig = JSON.parse(fs.readFileSync(smtpConfigPath, 'utf8'));
+      this.sender = new EmailSender(smtpConfig);
+      logger.info('SMTP configuration loaded');
+    } catch (error) {
+      logger.error('Failed to load SMTP configuration:', error);
+      return;
+    }
+
+    // Load email rules
+    const rulesPath = path.join(process.cwd(), 'email-rules.json');
+
+    if (!fs.existsSync(rulesPath)) {
+      logger.info('No email rules found at email-rules.json');
+      this.rules = [];
+      return;
+    }
+
+    try {
+      const rulesConfig = JSON.parse(fs.readFileSync(rulesPath, 'utf8'));
+      this.rules = rulesConfig.rules || [];
+      logger.info(`Loaded ${this.rules.length} email notification rules`);
+    } catch (error) {
+      logger.error('Failed to load email rules:', error);
+      this.rules = [];
+    }
+  }
+
+  /**
+   * Register callbacks with webhook handler
+   * @param {WebhookHandler} handler - The webhook handler instance
+   */
+  register(handler) {
+    if (!this.sender) {
+      logger.info('Email notifier not registered (SMTP not configured)');
+      return;
+    }
+
+    // Get unique event types from rules
+    const eventTypes = [...new Set(this.rules.map(rule => rule.event))];
+
+    for (const eventType of eventTypes) {
+      handler.on(eventType, async (payload, headers) => {
+        await this.processEvent(eventType, payload, headers);
+      });
+    }
+
+    logger.info(`Email notifier registered for events: ${eventTypes.join(', ')}`);
+  }
+
+  /**
+   * Process an event and send emails based on rules
+   */
+  async processEvent(eventType, payload, headers) {
+    const matchingRules = this.rules.filter(rule => rule.event === eventType);
+
+    for (const rule of matchingRules) {
+      try {
+        const shouldSend = await this.evaluateCondition(rule, payload);
+
+        if (shouldSend) {
+          await this.sendNotification(rule, payload);
+        }
+      } catch (error) {
+        logger.error(`Error processing email rule '${rule.name}':`, error);
+      }
+    }
+  }
+
+  /**
+   * Evaluate if a rule's condition is met
+   */
+  async evaluateCondition(rule, payload) {
+    // No condition = always send
+    if (!rule.condition) {
+      return true;
+    }
+
+    const { type, value } = rule.condition;
+
+    switch (type) {
+      case 'mention':
+        return this.checkMention(payload, value);
+
+      case 'branch':
+        return this.checkBranch(payload, value);
+
+      case 'action':
+        return this.checkAction(payload, value);
+
+      case 'user':
+        return this.checkUser(payload, value);
+
+      case 'repository':
+        return this.checkRepository(payload, value);
+
+      default:
+        logger.error(`Unknown condition type: ${type}`);
+        return false;
+    }
+  }
+
+  /**
+   * Check if a user is mentioned in the payload
+   */
+  checkMention(payload, username) {
+    // Check issue comment body
+    if (payload.comment && payload.comment.body) {
+      const mentionPattern = new RegExp(`@${username}\\b`, 'i');
+      return mentionPattern.test(payload.comment.body);
+    }
+
+    // Check issue body
+    if (payload.issue && payload.issue.body) {
+      const mentionPattern = new RegExp(`@${username}\\b`, 'i');
+      return mentionPattern.test(payload.issue.body);
+    }
+
+    // Check pull request body
+    if (payload.pull_request && payload.pull_request.body) {
+      const mentionPattern = new RegExp(`@${username}\\b`, 'i');
+      return mentionPattern.test(payload.pull_request.body);
+    }
+
+    return false;
+  }
+
+  /**
+   * Check if event is for a specific branch
+   */
+  checkBranch(payload, branchName) {
+    if (payload.ref) {
+      const branch = payload.ref.replace('refs/heads/', '');
+      return branch === branchName;
+    }
+    return false;
+  }
+
+  /**
+   * Check if action matches
+   */
+  checkAction(payload, actionName) {
+    return payload.action === actionName;
+  }
+
+  /**
+   * Check if user matches
+   */
+  checkUser(payload, username) {
+    // Check pusher
+    if (payload.pusher && payload.pusher.username) {
+      return payload.pusher.username === username;
+    }
+
+    // Check sender
+    if (payload.sender && payload.sender.login) {
+      return payload.sender.login === username;
+    }
+
+    return false;
+  }
+
+  /**
+   * Check if repository matches
+   */
+  checkRepository(payload, repoName) {
+    if (payload.repository && payload.repository.full_name) {
+      return payload.repository.full_name === repoName;
+    }
+    return false;
+  }
+
+  /**
+   * Send email notification
+   */
+  async sendNotification(rule, payload) {
+    const recipients = await this.resolveRecipients(rule.recipients, payload);
+
+    if (recipients.length === 0) {
+      logger.info(`No recipients found for rule '${rule.name}'`);
+      return;
+    }
+
+    const subject = this.substituteVariables(rule.subject, payload);
+    const body = this.substituteVariables(rule.body, payload);
+
+    // Get repository context for markdown rendering (enables issue/PR links)
+    const repositoryContext = payload.repository?.full_name || null;
+
+    // Render markdown to HTML
+    const htmlBody = await this.renderMarkdown(body, repositoryContext);
+
+    for (const recipient of recipients) {
+      try {
+        logger.info(`Sending email to ${recipient} for rule '${rule.name}'`);
+
+        await this.sender.send({
+          to: recipient,
+          subject: subject,
+          text: body,
+          html: htmlBody,
+        });
+
+        logger.info(`Email sent successfully to ${recipient}`);
+      } catch (error) {
+        logger.error(`Failed to send email to ${recipient}:`, error);
+      }
+    }
+  }
+
+  /**
+   * Resolve recipients (convert usernames to emails if needed)
+   */
+  async resolveRecipients(recipients, payload) {
+    const resolvedRecipients = [];
+
+    for (const recipient of recipients) {
+      if (recipient.includes('@')) {
+        // Already an email address
+        resolvedRecipients.push(recipient);
+      } else if (recipient === '{{mentioned_user}}') {
+        // Special variable for mentioned user
+        const mentionedUser = this.extractMentionedUser(payload);
+        if (mentionedUser) {
+          const email = await this.getUserEmail(mentionedUser, payload);
+          if (email) {
+            resolvedRecipients.push(email);
+          }
+        }
+      } else if (recipient === '{{sender}}') {
+        // Event sender
+        const sender = payload.sender || payload.pusher;
+        if (sender && sender.email) {
+          resolvedRecipients.push(sender.email);
+        }
+      } else if (recipient === '{{assignee}}') {
+        // Issue/PR assignee
+        const assignee = payload.issue?.assignee || payload.pull_request?.assignee;
+        if (assignee && assignee.email) {
+          resolvedRecipients.push(assignee.email);
+        }
+      } else {
+        // Assume it's a username, try to get email
+        const email = await this.getUserEmail(recipient, payload);
+        if (email) {
+          resolvedRecipients.push(email);
+        }
+      }
+    }
+
+    return [...new Set(resolvedRecipients)]; // Remove duplicates
+  }
+
+  /**
+   * Extract mentioned username from payload
+   */
+  extractMentionedUser(payload) {
+    const body = payload.comment?.body || payload.issue?.body || payload.pull_request?.body;
+
+    if (!body) {
+      return null;
+    }
+
+    // Find first @mention
+    const match = body.match(/@(\w+)/);
+    return match ? match[1] : null;
+  }
+
+  /**
+   * Get user email (from cache or payload)
+   */
+  async getUserEmail(username, payload) {
+    // Check cache
+    if (this.userEmails.has(username)) {
+      return this.userEmails.get(username);
+    }
+
+    // Try to find email in payload
+    const users = [
+      payload.sender,
+      payload.pusher,
+      payload.issue?.user,
+      payload.issue?.assignee,
+      payload.pull_request?.user,
+      payload.pull_request?.assignee,
+      payload.comment?.user,
+    ].filter(Boolean);
+
+    for (const user of users) {
+      if (user.username === username || user.login === username) {
+        if (user.email) {
+          this.userEmails.set(username, user.email);
+          return user.email;
+        }
+      }
+    }
+
+    logger.error(`Could not find email for user: ${username}`);
+    return null;
+  }
+
+  /**
+   * Substitute variables in template strings
+   */
+  substituteVariables(template, payload) {
+    let result = template;
+
+    const variables = {
+      '{{repo}}': payload.repository?.name || '',
+      '{{full_repo}}': payload.repository?.full_name || '',
+      '{{repo_owner}}': payload.repository?.owner?.username || payload.repository?.owner?.login || '',
+      '{{branch}}': this.extractBranch(payload),
+      '{{sender}}': payload.sender?.username || payload.sender?.login || payload.pusher?.username || '',
+      '{{commit}}': payload.after?.substring(0, 7) || '',
+      '{{commit_msg}}': payload.commits?.[payload.commits.length - 1]?.message || '',
+      '{{pr_number}}': payload.pull_request?.number || payload.number || '',
+      '{{pr_action}}': payload.action || '',
+      '{{issue_number}}': payload.issue?.number || payload.number || '',
+      '{{issue_title}}': payload.issue?.title || payload.pull_request?.title || '',
+      '{{issue_action}}': payload.action || '',
+      '{{comment_body}}': payload.comment?.body || '',
+      '{{mentioned_user}}': this.extractMentionedUser(payload) || '',
+    };
+
+    for (const [key, value] of Object.entries(variables)) {
+      result = result.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value);
+    }
+
+    return result;
+  }
+
+  /**
+   * Extract branch name from payload
+   */
+  extractBranch(payload) {
+    if (payload.ref) {
+      return payload.ref.replace('refs/heads/', '').replace('refs/tags/', '');
+    }
+    if (payload.pull_request && payload.pull_request.head) {
+      return payload.pull_request.head.ref;
+    }
+    return '';
+  }
+
+  /**
+   * Render markdown to HTML using Gogs API
+   */
+  async renderMarkdown(text, repositoryContext = null) {
+    const gogsUrl = config.get('GOGS_SERVER_URL');
+    const gogsToken = config.get('GOGS_ACCESS_TOKEN');
+
+    if (!gogsUrl) {
+      logger.error('GOGS_SERVER_URL not configured, falling back to plain text');
+      return this.convertToPlainHtml(text);
+    }
+
+    try {
+      const url = new URL('/api/v1/markdown', gogsUrl);
+      const requestData = JSON.stringify({
+        text: text,
+        mode: repositoryContext ? 'gfm' : 'markdown',
+        context: repositoryContext || '',
+      });
+
+      const options = {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Content-Length': Buffer.byteLength(requestData),
+        },
+      };
+
+      // Add authorization header if token is available
+      if (gogsToken) {
+        options.headers['Authorization'] = `token ${gogsToken}`;
+      }
+
+      const htmlContent = await this._makeHttpsRequest(url, options, requestData);
+      return this._wrapInHtmlTemplate(htmlContent);
+    } catch (error) {
+      logger.error('Failed to render markdown via Gogs API:', error);
+      return this.convertToPlainHtml(text);
+    }
+  }
+
+  /**
+   * Make HTTPS request to Gogs API
+   */
+  _makeHttpsRequest(url, options, data) {
+    return new Promise((resolve, reject) => {
+      const req = https.request(url, options, (res) => {
+        let body = '';
+
+        res.on('data', (chunk) => {
+          body += chunk;
+        });
+
+        res.on('end', () => {
+          if (res.statusCode >= 200 && res.statusCode < 300) {
+            resolve(body);
+          } else {
+            reject(new Error(`HTTP ${res.statusCode}: ${body}`));
+          }
+        });
+      });
+
+      req.on('error', (err) => {
+        reject(err);
+      });
+
+      req.setTimeout(10000, () => {
+        req.destroy();
+        reject(new Error('Request timeout'));
+      });
+
+      if (data) {
+        req.write(data);
+      }
+
+      req.end();
+    });
+  }
+
+  /**
+   * Wrap rendered HTML in a full HTML document with styling
+   */
+  _wrapInHtmlTemplate(htmlContent) {
+    return `
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <style>
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+      line-height: 1.6;
+      color: #24292e;
+      max-width: 800px;
+      margin: 0 auto;
+      padding: 20px;
+    }
+    pre {
+      background: #f6f8fa;
+      padding: 16px;
+      border-radius: 6px;
+      overflow-x: auto;
+    }
+    code {
+      background: #f6f8fa;
+      padding: 2px 6px;
+      border-radius: 3px;
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+      font-size: 85%;
+    }
+    pre code {
+      padding: 0;
+    }
+    a {
+      color: #0366d6;
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    blockquote {
+      padding: 0 1em;
+      color: #6a737d;
+      border-left: 0.25em solid #dfe2e5;
+      margin: 0 0 16px 0;
+    }
+    table {
+      border-collapse: collapse;
+      width: 100%;
+      margin-bottom: 16px;
+    }
+    table th,
+    table td {
+      padding: 6px 13px;
+      border: 1px solid #dfe2e5;
+    }
+    table tr:nth-child(2n) {
+      background-color: #f6f8fa;
+    }
+    img {
+      max-width: 100%;
+      box-sizing: border-box;
+    }
+    hr {
+      height: 0.25em;
+      padding: 0;
+      margin: 24px 0;
+      background-color: #e1e4e8;
+      border: 0;
+    }
+  </style>
+</head>
+<body>
+  ${htmlContent}
+</body>
+</html>
+    `.trim();
+  }
+
+  /**
+   * Convert plain text to basic HTML (fallback)
+   */
+  convertToPlainHtml(text) {
+    return `
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <style>
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+      line-height: 1.6;
+      color: #24292e;
+      max-width: 800px;
+      margin: 0 auto;
+      padding: 20px;
+    }
+    pre {
+      background: #f6f8fa;
+      padding: 16px;
+      border-radius: 6px;
+      overflow-x: auto;
+      white-space: pre-wrap;
+      word-wrap: break-word;
+    }
+  </style>
+</head>
+<body>
+  <pre>${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
+</body>
+</html>
+    `.trim();
+  }
+}
+
+export default new EmailNotifier();