|
@@ -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, '<').replace(/>/g, '>')}</pre>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+ `.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default new EmailNotifier();
|