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('webhook') }}
+
+
+
+
+
+
+
+
+
+ {{ t('webhookRetry') }}
+
+
+
+
+
+
+
+
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;