Преглед на файлове

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 преди 9 месеца
родител
ревизия
d6b131c7ec
променени са 1 файла, в които са добавени 576 реда и са изтрити 0 реда
  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();