diff --git a/package-lock.json b/package-lock.json index cb50813f..10462d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", @@ -7077,6 +7078,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz", diff --git a/package.json b/package.json index 86424cea..7e0c9e98 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "ioredis": "^5.3.2", "ldapjs": "^3.0.7", "morgan": "^1.10.0", + "nodemailer": "^7.0.6", "ora": "^5.4.1", "rate-limiter-flexible": "^5.0.5", "socks-proxy-agent": "^8.0.2", diff --git a/src/routes/webhook.js b/src/routes/webhook.js index f4c70b41..4af05fb6 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -124,7 +124,16 @@ router.post('/test', authenticateAdmin, async (req, res) => { serverUrl, level, sound, - group + group, + // SMTP 相关字段 + host, + port, + secure, + user, + pass, + from, + to, + ignoreTLS } = req.body // Bark平台特殊处理 @@ -149,6 +158,34 @@ router.post('/test', authenticateAdmin, async (req, res) => { } logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`) + } else if (type === 'smtp') { + // SMTP平台验证 + if (!host) { + return res.status(400).json({ + error: 'Missing SMTP host', + message: '请提供SMTP服务器地址' + }) + } + if (!user) { + return res.status(400).json({ + error: 'Missing SMTP user', + message: '请提供SMTP用户名' + }) + } + if (!pass) { + return res.status(400).json({ + error: 'Missing SMTP password', + message: '请提供SMTP密码' + }) + } + if (!to) { + return res.status(400).json({ + error: 'Missing recipient email', + message: '请提供收件人邮箱' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`) } else { // 其他平台验证URL if (!url) { @@ -188,6 +225,16 @@ router.post('/test', authenticateAdmin, async (req, res) => { platform.level = level platform.sound = sound platform.group = group + } else if (type === 'smtp') { + // 添加SMTP特有字段 + platform.host = host + platform.port = port || 587 + platform.secure = secure || false + platform.user = user + platform.pass = pass + platform.from = from + platform.to = to + platform.ignoreTLS = ignoreTLS || false } const result = await webhookService.testWebhook(platform) diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js index 59955cd1..df0f36fb 100644 --- a/src/services/webhookConfigService.js +++ b/src/services/webhookConfigService.js @@ -63,7 +63,8 @@ class WebhookConfigService { 'slack', 'discord', 'custom', - 'bark' + 'bark', + 'smtp' ] for (const platform of config.platforms) { @@ -71,8 +72,8 @@ class WebhookConfigService { throw new Error(`不支持的平台类型: ${platform.type}`) } - // Bark平台使用deviceKey而不是url - if (platform.type !== 'bark') { + // Bark和SMTP平台不使用标准URL + if (platform.type !== 'bark' && platform.type !== 'smtp') { if (!platform.url || !this.isValidUrl(platform.url)) { throw new Error(`无效的webhook URL: ${platform.url}`) } @@ -201,6 +202,51 @@ class WebhookConfigService { logger.warn('⚠️ Bark点击跳转URL格式可能不正确') } break + case 'smtp': { + // 验证SMTP必需配置 + if (!platform.host) { + throw new Error('SMTP平台必须提供主机地址') + } + if (!platform.user) { + throw new Error('SMTP平台必须提供用户名') + } + if (!platform.pass) { + throw new Error('SMTP平台必须提供密码') + } + if (!platform.to) { + throw new Error('SMTP平台必须提供接收邮箱') + } + + // 验证端口 + if (platform.port && (platform.port < 1 || platform.port > 65535)) { + throw new Error('SMTP端口必须在1-65535之间') + } + + // 验证邮箱格式 + // 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name + const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + // 验证接收邮箱 + const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to] + for (const email of toEmails) { + // 提取实际邮箱地址(如果是 Name 格式) + const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email + if (!actualEmail || !simpleEmailRegex.test(actualEmail)) { + throw new Error(`无效的接收邮箱格式: ${email}`) + } + } + + // 验证发送邮箱(支持 Name 格式) + if (platform.from) { + const actualFromEmail = platform.from.includes('<') + ? platform.from.match(/<([^>]+)>/)?.[1] + : platform.from + if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) { + throw new Error(`无效的发送邮箱格式: ${platform.from}`) + } + } + break + } } } diff --git a/src/services/webhookService.js b/src/services/webhookService.js index c0d049c5..4aa66cd9 100755 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -1,5 +1,6 @@ const axios = require('axios') const crypto = require('crypto') +const nodemailer = require('nodemailer') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') const { getISOStringWithTimezone } = require('../utils/dateHelper') @@ -14,7 +15,8 @@ class WebhookService { slack: this.sendToSlack.bind(this), discord: this.sendToDiscord.bind(this), custom: this.sendToCustom.bind(this), - bark: this.sendToBark.bind(this) + bark: this.sendToBark.bind(this), + smtp: this.sendToSMTP.bind(this) } this.timezone = appConfig.system.timezone || 'Asia/Shanghai' } @@ -243,6 +245,51 @@ class WebhookService { await this.sendHttpRequest(url, payload, platform.timeout || 10000) } + /** + * SMTP邮件通知 + */ + async sendToSMTP(platform, type, data) { + try { + // 创建SMTP传输器 + const transporter = nodemailer.createTransport({ + host: platform.host, + port: platform.port || 587, + secure: platform.secure || false, // true for 465, false for other ports + auth: { + user: platform.user, + pass: platform.pass + }, + // 可选的TLS配置 + tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined, + // 连接超时 + connectionTimeout: platform.timeout || 10000 + }) + + // 构造邮件内容 + const subject = this.getNotificationTitle(type) + const htmlContent = this.formatMessageForEmail(type, data) + const textContent = this.formatMessageForEmailText(type, data) + + // 邮件选项 + const mailOptions = { + from: platform.from || platform.user, // 发送者 + to: platform.to, // 接收者(必填) + subject: `[Claude Relay Service] ${subject}`, + text: textContent, + html: htmlContent + } + + // 发送邮件 + const info = await transporter.sendMail(mailOptions) + logger.info(`✅ 邮件发送成功: ${info.messageId}`) + + return info + } catch (error) { + logger.error('SMTP邮件发送失败:', error) + throw error + } + } + /** * 发送HTTP请求 */ @@ -459,6 +506,121 @@ class WebhookService { return lines.join('\n') } + /** + * 构建通知详情数据 + */ + buildNotificationDetails(data) { + const details = [] + + if (data.accountName) { + details.push({ label: '账号', value: data.accountName }) + } + if (data.platform) { + details.push({ label: '平台', value: data.platform }) + } + if (data.status) { + details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) }) + } + if (data.errorCode) { + details.push({ label: '错误代码', value: data.errorCode, isCode: true }) + } + if (data.reason) { + details.push({ label: '原因', value: data.reason }) + } + if (data.message) { + details.push({ label: '消息', value: data.message }) + } + if (data.quota) { + details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` }) + } + if (data.usage) { + details.push({ label: '使用率', value: `${data.usage}%` }) + } + + return details + } + + /** + * 格式化邮件HTML内容 + */ + formatMessageForEmail(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = ` +
+
+

${title}

+

Claude Relay Service

+
+
+
+ ` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + if (detail.isCode) { + content += `

${detail.label}: ${detail.value}

` + } else if (detail.color) { + content += `

${detail.label}: ${detail.value}

` + } else { + content += `

${detail.label}: ${detail.value}

` + } + }) + + content += ` +
+
+

发送时间: ${timestamp}

+

此邮件由 Claude Relay Service 自动发送

+
+
+
+ ` + + return content + } + + /** + * 格式化邮件纯文本内容 + */ + formatMessageForEmailText(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = `${title}\n` + content += `=====================================\n\n` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + content += `${detail.label}: ${detail.value}\n` + }) + + content += `\n发送时间: ${timestamp}\n` + content += `服务: Claude Relay Service\n` + content += `=====================================\n` + content += `此邮件由系统自动发送,请勿回复。` + + return content + } + + /** + * 获取状态颜色 + */ + getStatusColor(status) { + const colors = { + error: '#dc3545', + unauthorized: '#fd7e14', + blocked: '#6f42c1', + disabled: '#6c757d', + active: '#28a745', + warning: '#ffc107' + } + return colors[status] || '#007bff' + } + /** * 格式化通知详情 */ diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 3fd7b3ad..770f43c3 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -471,10 +471,22 @@
-
+
{{ platform.url }}
+
+ + {{ + Array.isArray(platform.to) ? platform.to.join(', ') : platform.to + }} +
🟣 Slack +
@@ -684,8 +697,8 @@ />
- -
+ +
+ +
+ +
+ + +
+ + +
+
+ + +

+ 默认: 587 (TLS) 或 465 (SSL) +

+
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +

+ 建议使用应用专用密码,而非邮箱登录密码 +

+
+ + +
+ + +
+ + +
+ + +

接收通知的邮箱地址

+
+
+
{ if (platformForm.value.type === 'bark') { // Bark平台需要deviceKey return !!platformForm.value.deviceKey + } else if (platformForm.value.type === 'smtp') { + // SMTP平台需要必要的配置 + return !!( + platformForm.value.host && + platformForm.value.user && + platformForm.value.pass && + platformForm.value.to + ) } else { // 其他平台需要URL且URL格式正确 return !!platformForm.value.url && !urlError.value @@ -1134,8 +1337,8 @@ const saveWebhookConfig = async () => { // 验证 URL const validateUrl = () => { - // Bark平台不需要验证URL - if (platformForm.value.type === 'bark') { + // Bark和SMTP平台不需要验证URL + if (platformForm.value.type === 'bark' || platformForm.value.type === 'smtp') { urlError.value = false urlValid.value = false return @@ -1163,27 +1366,46 @@ const validateUrl = () => { } } -// 添加/更新平台 -const savePlatform = async () => { - if (!isMounted.value) return - - // Bark平台只需要deviceKey,其他平台需要URL +// 验证平台配置 +const validatePlatformForm = () => { if (platformForm.value.type === 'bark') { if (!platformForm.value.deviceKey) { showToast('请输入Bark设备密钥', 'error') - return + return false + } + } else if (platformForm.value.type === 'smtp') { + const requiredFields = [ + { field: 'host', message: 'SMTP服务器' }, + { field: 'user', message: '用户名' }, + { field: 'pass', message: '密码' }, + { field: 'to', message: '收件人邮箱' } + ] + + for (const { field, message } of requiredFields) { + if (!platformForm.value[field]) { + showToast(`请输入${message}`, 'error') + return false + } } } else { if (!platformForm.value.url) { showToast('请输入Webhook URL', 'error') - return + return false } - if (urlError.value) { showToast('请输入有效的Webhook URL', 'error') - return + return false } } + return true +} + +// 添加/更新平台 +const savePlatform = async () => { + if (!isMounted.value) return + + // 验证表单 + if (!validatePlatformForm()) return savingPlatform.value = true try { @@ -1300,7 +1522,7 @@ const testPlatform = async (platform) => { signal: abortController.value.signal }) if (response.success && isMounted.value) { - showToast('测试成功,webhook连接正常', 'success') + showToast('测试成功', 'success') } } catch (error) { if (error.name === 'AbortError') return @@ -1314,24 +1536,8 @@ const testPlatform = async (platform) => { const testPlatformForm = async () => { if (!isMounted.value) return - // Bark平台验证 - if (platformForm.value.type === 'bark') { - if (!platformForm.value.deviceKey) { - showToast('请先输入Bark设备密钥', 'error') - return - } - } else { - // 其他平台验证URL - if (!platformForm.value.url) { - showToast('请先输入Webhook URL', 'error') - return - } - - if (urlError.value) { - showToast('请输入有效的Webhook URL', 'error') - return - } - } + // 验证表单 + if (!validatePlatformForm()) return testingConnection.value = true try { @@ -1339,7 +1545,7 @@ const testPlatformForm = async () => { signal: abortController.value.signal }) if (response.success && isMounted.value) { - showToast('测试成功,webhook连接正常', 'success') + showToast('测试成功', 'success') } } catch (error) { if (error.name === 'AbortError') return @@ -1397,7 +1603,17 @@ const closePlatformModal = () => { serverUrl: '', level: '', sound: '', - group: '' + group: '', + // SMTP特有字段 + host: '', + port: null, + secure: false, + user: '', + pass: '', + from: '', + to: '', + timeout: null, + ignoreTLS: false } urlError.value = false urlValid.value = false @@ -1415,6 +1631,7 @@ const getPlatformName = (type) => { slack: 'Slack', discord: 'Discord', bark: 'Bark', + smtp: '邮件通知', custom: '自定义' } return names[type] || type @@ -1428,6 +1645,7 @@ const getPlatformIcon = (type) => { slack: 'fab fa-slack text-purple-600', discord: 'fab fa-discord text-indigo-600', bark: 'fas fa-bell text-orange-500', + smtp: 'fas fa-envelope text-blue-600', custom: 'fas fa-webhook text-gray-600' } return icons[type] || 'fas fa-bell' @@ -1441,6 +1659,7 @@ const getWebhookHint = (type) => { slack: '请在Slack应用的Incoming Webhooks中获取地址', discord: '请在Discord服务器的集成设置中创建Webhook', bark: '请在Bark App中查看您的设备密钥', + smtp: '请配置SMTP服务器信息,支持Gmail、QQ邮箱等', custom: '请输入完整的Webhook接收地址' } return hints[type] || ''