From 57a4b1fa8445ac4929fea184bea78a539c315465 Mon Sep 17 00:00:00 2001 From: aslost <225811079+aslost@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:01 +0800 Subject: [PATCH] feat: add webhook --- mail-vue/src/i18n/en.js | 7 + mail-vue/src/i18n/zh.js | 7 + mail-vue/src/utils/verify-utils.js | 13 ++ mail-vue/src/views/sys-setting/index.vue | 207 +++++++++++++++++---- mail-worker/src/const/entity-const.js | 4 + mail-worker/src/email/email.js | 10 + mail-worker/src/entity/setting.js | 6 +- mail-worker/src/hono/hono.js | 10 +- mail-worker/src/i18n/en.js | 1 + mail-worker/src/i18n/zh.js | 1 + mail-worker/src/init/init.js | 11 ++ mail-worker/src/service/email-service.js | 4 + mail-worker/src/service/setting-service.js | 5 + mail-worker/src/service/webhook-service.js | 64 +++++++ 14 files changed, 311 insertions(+), 39 deletions(-) create mode 100644 mail-worker/src/service/webhook-service.js diff --git a/mail-vue/src/i18n/en.js b/mail-vue/src/i18n/en.js index 45bbd3d..1f386c5 100644 --- a/mail-vue/src/i18n/en.js +++ b/mail-vue/src/i18n/en.js @@ -172,6 +172,13 @@ const en = { disable: 'Disable', disabled: 'Disabled', otherEmail: 'Forwarding to External Email', + webhook: 'Webhook', + webhookDesc: 'POST received emails as JSON to this URL', + webhookUrl: 'Request URL', + webhookIpNotSupported: 'IP addresses are not supported. Use a domain name.', + webhookSecret: 'Secret (Optional)', + webhookRetry: 'Retries', + webhookFormat: 'Payload format', forwardingRules: 'Forwarding Rules', forwardAll: 'All', rules: 'Rules', diff --git a/mail-vue/src/i18n/zh.js b/mail-vue/src/i18n/zh.js index b9f3f96..9c5f78c 100644 --- a/mail-vue/src/i18n/zh.js +++ b/mail-vue/src/i18n/zh.js @@ -172,6 +172,13 @@ const zh = { disable: '关闭', disabled: '已关闭', otherEmail: '第三方邮箱', + webhook: 'Webhook', + webhookDesc: '收到邮件后向指定地址 POST JSON', + webhookUrl: '请求地址', + webhookIpNotSupported: '不能使用 IP 地址,请填写域名', + webhookSecret: '密钥(可选)', + webhookRetry: '重试次数', + webhookFormat: '格式说明', forwardingRules: '转发规则', forwardAll: '全部转发', rules: '规则转发', diff --git a/mail-vue/src/utils/verify-utils.js b/mail-vue/src/utils/verify-utils.js index 98eda7c..fc4b7d8 100644 --- a/mail-vue/src/utils/verify-utils.js +++ b/mail-vue/src/utils/verify-utils.js @@ -5,4 +5,17 @@ export function isEmail(email) { export function isDomain(str) { return /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/.test(str); +} + +export function isIpUrl(url) { + if (!url) return false + try { + const { hostname } = new URL(url.startsWith('http') ? url : 'https://' + url) + if (/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/.test(hostname)) { + return true + } + return hostname.includes(':') + } catch { + return false + } } \ No newline at end of file diff --git a/mail-vue/src/views/sys-setting/index.vue b/mail-vue/src/views/sys-setting/index.vue index c6e49f0..581f769 100644 --- a/mail-vue/src/views/sys-setting/index.vue +++ b/mail-vue/src/views/sys-setting/index.vue @@ -231,6 +231,48 @@ +
+
{{ $t('emailPush') }}
+
+
+
{{ $t('tgBot') }}
+
+ {{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }} + + + +
+
+
+
{{ $t('otherEmail') }}
+
+ {{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }} + + + +
+
+
+
{{ $t('webhook') }}
+
+ {{ setting.webhookStatus === 0 ? $t('enabled') : $t('disabled') }} + + + +
+
+
+
{{ $t('forwardingRules') }}
+
+ {{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }} + + + +
+
+
+
+
{{ $t('oss') }}
@@ -272,39 +314,6 @@
-
-
{{ $t('emailPush') }}
-
-
-
{{ $t('tgBot') }}
-
- {{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }} - - - -
-
-
-
{{ $t('otherEmail') }}
-
- {{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }} - - - -
-
-
-
{{ $t('forwardingRules') }}
-
- {{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }} - - - -
-
-
-
-
{{ $t('turnstileSetting') }}
@@ -659,6 +668,48 @@
+ + +
+ + +
+ {{ t('webhookRetry') }} + +
+
+
Content-Type: application/json
+Authorization: <secret>
+
{{ webhookPayloadExample }}
+
+
+ +
Hello", + "code": "123456", + "createTime": "2099-12-30 23:59:59" +}` const emailColumnWidth = ref(0) const tokenColumnWidth = ref(0) const ruleType = ref(0) @@ -1202,6 +1271,14 @@ function openThirdEmailSetting() { thirdEmailShow.value = true } +function openWebhookSetting() { + webhookStatus.value = setting.value.webhookStatus + webhookUrl.value = setting.value.webhookUrl || '' + webhookRetry.value = setting.value.webhookRetry ?? 0 + webhookSecret.value = setting.value.webhookSecret || '' + webhookShow.value = true +} + function openEmailPrefix() { emailPrefixShow.value = true } @@ -1317,6 +1394,29 @@ function forwardEmailSave() { editSetting(form) } +function webhookSave() { + let retry = Number(webhookRetry.value) + if (isNaN(retry) || retry < 0) { + retry = 0 + } + const url = toOssDomain(webhookUrl.value.trim()) + if (isIpUrl(url)) { + ElMessage({ + message: t('webhookIpNotSupported'), + type: 'warning', + plain: true + }) + return + } + const form = { + webhookStatus: webhookStatus.value, + webhookUrl: url, + webhookRetry: retry, + webhookSecret: webhookSecret.value.trim() + } + editSetting(form) +} + function ruleEmailSave() { const form = { @@ -1635,6 +1735,7 @@ function editSetting(settingForm, refreshStatus = true) { turnstileShow.value = false tgSettingShow.value = false thirdEmailShow.value = false + webhookShow.value = false forwardRulesShow.value = false addVerifyCountShow.value = false regVerifyCountShow.value = false @@ -1829,6 +1930,29 @@ function editSetting(settingForm, refreshStatus = true) { .dialog-footer { display: flex; justify-content: space-between; + align-items: center; +} + +.webhook-footer-right { + display: flex; + align-items: center; + gap: 12px; +} + +.webhook-format-title { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; + color: var(--el-color-primary); + font-size: 13px; +} + +.webhook-format-icon { + transition: transform 0.2s; + &.open { + transform: rotate(180deg); + } } .notice-popup-item { @@ -1987,6 +2111,19 @@ function editSetting(settingForm, refreshStatus = true) { .el-select { width: v-bind(tgMsgLabelWidth); } + .el-input-number { + width: 120px; + } + } + + .webhook-format pre { + margin: 8px 0 0; + padding: 10px; + font-size: 12px; + line-height: 1.5; + overflow-x: auto; + border-radius: 4px; + background: var(--el-fill-color-light); } } diff --git a/mail-worker/src/const/entity-const.js b/mail-worker/src/const/entity-const.js index 5d6c846..cadbb81 100644 --- a/mail-worker/src/const/entity-const.js +++ b/mail-worker/src/const/entity-const.js @@ -112,6 +112,10 @@ export const settingConst = { OPEN: 0, CLOSE: 1, }, + webhookStatus: { + OPEN: 0, + CLOSE: 1, + }, ruleType: { ALL: 0, RULE: 1 diff --git a/mail-worker/src/email/email.js b/mail-worker/src/email/email.js index a19d157..79a14f2 100644 --- a/mail-worker/src/email/email.js +++ b/mail-worker/src/email/email.js @@ -11,6 +11,7 @@ import roleService from '../service/role-service'; import userService from '../service/user-service'; import telegramService from '../service/telegram-service'; import aiService from '../service/ai-service'; +import webhookService from '../service/webhook-service'; export async function email(message, env, ctx) { @@ -22,6 +23,10 @@ export async function email(message, env, ctx) { tgBotStatus, forwardStatus, forwardEmail, + webhookStatus, + webhookUrl, + webhookRetry, + webhookSecret, ruleEmail, ruleType, r2Domain, @@ -186,6 +191,11 @@ export async function email(message, env, ctx) { } + //转发到 Webhook + if (webhookStatus === settingConst.webhookStatus.OPEN && webhookUrl) { + await webhookService.sendEmail({ env }, emailRow, webhookUrl, webhookRetry, webhookSecret); + } + } catch (e) { console.error('邮件接收异常: ', e); throw e diff --git a/mail-worker/src/entity/setting.js b/mail-worker/src/entity/setting.js index c4de64e..fc1483b 100644 --- a/mail-worker/src/entity/setting.js +++ b/mail-worker/src/entity/setting.js @@ -63,6 +63,10 @@ export const setting = sqliteTable('setting', { googleClientSecret: text('google_client_secret').default('').notNull(), googleSwitch: integer('google_switch').default(1).notNull(), autoCleanDays: integer('auto_clean_days').default(0).notNull(), - autoCleanExclude: text('auto_clean_exclude').default('').notNull() + autoCleanExclude: text('auto_clean_exclude').default('').notNull(), + webhookUrl: text('webhook_url').default('').notNull(), + webhookStatus: integer('webhook_status').default(1).notNull(), + webhookRetry: integer('webhook_retry').default(0).notNull(), + webhookSecret: text('webhook_secret').default('').notNull() }); export default setting diff --git a/mail-worker/src/hono/hono.js b/mail-worker/src/hono/hono.js index 910ba2f..4fca43a 100644 --- a/mail-worker/src/hono/hono.js +++ b/mail-worker/src/hono/hono.js @@ -14,15 +14,19 @@ app.onError((err, c) => { } if (err.message === `Cannot read properties of undefined (reading 'get')`) { - return c.json(result.fail('KV数据库未绑定 KV database not bound',502)); + return c.json(result.fail('KV数据库未绑定
KV database not bound',502)); } if (err.message === `Cannot read properties of undefined (reading 'put')`) { - return c.json(result.fail('KV数据库未绑定 KV database not bound',502)); + return c.json(result.fail('KV数据库未绑定
KV database not bound',502)); } if (err.message === `Cannot read properties of undefined (reading 'prepare')`) { - return c.json(result.fail('D1数据库未绑定 D1 database not bound',502)); + return c.json(result.fail('D1数据库未绑定
D1 database not bound',502)); + } + + if (err.message?.includes('D1_ERROR: no such column')) { + return c.json(result.fail('请按照文档更新数据库
Please update the database as documented',502)); } return c.json(result.fail(err.message, err.code)); diff --git a/mail-worker/src/i18n/en.js b/mail-worker/src/i18n/en.js index 26d46b9..d471511 100644 --- a/mail-worker/src/i18n/en.js +++ b/mail-worker/src/i18n/en.js @@ -3,6 +3,7 @@ const en = { addAccountDisabled: 'Add Email Address feature is disabled', regDisabled: 'Sign up is disabled', emptyEmail: 'Email cannot be empty', + emptyAccountId: 'accountId cannot be empty', notEmail: 'Invalid email', notExistDomain: 'Email domain does not exist', isDelAccount: 'This Email has been deleted', diff --git a/mail-worker/src/i18n/zh.js b/mail-worker/src/i18n/zh.js index e2bf313..567dcbc 100644 --- a/mail-worker/src/i18n/zh.js +++ b/mail-worker/src/i18n/zh.js @@ -3,6 +3,7 @@ const zh = { addAccountDisabled: '添加邮箱功能已关闭', regDisabled: '注册功能已关闭', emptyEmail: '邮箱不能为空', + emptyAccountId: 'accountId不能为空', notEmail: '非法邮箱', notExistDomain: '不存在的邮箱域名', isDelAccount: '该邮箱已被注销', diff --git a/mail-worker/src/init/init.js b/mail-worker/src/init/init.js index 6f49ffe..1f15f6b 100644 --- a/mail-worker/src/init/init.js +++ b/mail-worker/src/init/init.js @@ -46,6 +46,17 @@ const dbInit = { } catch (e) { console.warn(`跳过字段:${e.message}`); } + + try { + await c.env.db.batch([ + c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_url TEXT NOT NULL DEFAULT '';`), + c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_status INTEGER NOT NULL DEFAULT 1;`), + c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_retry INTEGER NOT NULL DEFAULT 0;`), + c.env.db.prepare(`ALTER TABLE setting ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT '';`) + ]); + } catch (e) { + console.warn(`跳过字段:${e.message}`); + } }, async v3_2DB(c) { diff --git a/mail-worker/src/service/email-service.js b/mail-worker/src/service/email-service.js index 644fe00..247ad71 100644 --- a/mail-worker/src/service/email-service.js +++ b/mail-worker/src/service/email-service.js @@ -42,6 +42,10 @@ const emailService = { type = 0; } + if (isNaN(accountId)) { + throw new BizError(t('emptyAccountId')); + } + if (isNaN(size)) { size = 10; } diff --git a/mail-worker/src/service/setting-service.js b/mail-worker/src/service/setting-service.js index e43c2f5..8160841 100644 --- a/mail-worker/src/service/setting-service.js +++ b/mail-worker/src/service/setting-service.js @@ -9,6 +9,7 @@ import BizError from '../error/biz-error'; import {t} from '../i18n/i18n' import verifyRecordService from './verify-record-service'; import userContext from '../security/user-context'; +import domainUtils from '../utils/domain-uitls'; const settingService = { @@ -124,6 +125,10 @@ const settingService = { params.aiCodeFilter = params.aiCodeFilter + ''; } + if (params.webhookUrl !== undefined) { + params.webhookUrl = domainUtils.toOssDomain(params.webhookUrl) || ''; + } + params.resendTokens = JSON.stringify(resendTokens); await orm(c).update(setting).set({ ...params }).returning().get(); diff --git a/mail-worker/src/service/webhook-service.js b/mail-worker/src/service/webhook-service.js new file mode 100644 index 0000000..834369e --- /dev/null +++ b/mail-worker/src/service/webhook-service.js @@ -0,0 +1,64 @@ +import domainUtils from '../utils/domain-uitls'; + +const webhookService = { + + async sendEmail(c, emailRow, webhookUrl, retry = 0, webhookSecret) { + + webhookUrl = domainUtils.toOssDomain(webhookUrl); + + if (!webhookUrl) { + return; + } + + retry = Number(retry); + if (isNaN(retry) || retry < 0) { + retry = 0; + } + + const headers = { + 'Content-Type': 'application/json' + }; + + if (webhookSecret) { + headers['Authorization'] = webhookSecret; + } + + const body = JSON.stringify({ + emailId: emailRow.emailId, + sendEmail: emailRow.sendEmail, + sendName: emailRow.name, + toEmail: emailRow.toEmail, + toName: emailRow.toName, + subject: emailRow.subject, + text: emailRow.text, + content: emailRow.content, + code: emailRow.code, + createTime: emailRow.createTime + }); + + let lastError = ''; + + for (let i = 0; i <= retry; i++) { + try { + const res = await fetch(webhookUrl, { + method: 'POST', + headers, + body + }); + + if (res.ok) { + return; + } + + lastError = `status: ${res.status} response: ${await res.text()}`; + } catch (e) { + lastError = e.message; + } + } + + console.error(`Webhook 推送失败: ${lastError}`); + } + +}; + +export default webhookService;