feat: add webhook

This commit is contained in:
aslost
2026-08-29 18:05:01 +08:00
parent c344351c8b
commit 57a4b1fa84
14 changed files with 311 additions and 39 deletions
+7
View File
@@ -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',
+7
View File
@@ -172,6 +172,13 @@ const zh = {
disable: '关闭',
disabled: '已关闭',
otherEmail: '第三方邮箱',
webhook: 'Webhook',
webhookDesc: '收到邮件后向指定地址 POST JSON',
webhookUrl: '请求地址',
webhookIpNotSupported: '不能使用 IP 地址,请填写域名',
webhookSecret: '密钥(可选)',
webhookRetry: '重试次数',
webhookFormat: '格式说明',
forwardingRules: '转发规则',
forwardAll: '全部转发',
rules: '规则转发',
+13
View File
@@ -6,3 +6,16 @@ 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
}
}
+172 -35
View File
@@ -231,6 +231,48 @@
</div>
</div>
<div class="settings-card">
<div class="card-title">{{ $t('emailPush') }}</div>
<div class="card-content">
<div class="setting-item">
<div><span>{{ $t('tgBot') }}</span></div>
<div class="forward">
<span>{{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openTgSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('otherEmail') }}</span></div>
<div class="forward">
<span>{{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openThirdEmailSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('webhook') }}</span></div>
<div class="forward">
<span>{{ setting.webhookStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openWebhookSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('forwardingRules') }}</span></div>
<div class="forward">
<span>{{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openForwardRules">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
</div>
</div>
<!-- Object Storage Card -->
<div class="settings-card">
<div class="card-title">{{ $t('oss') }}</div>
@@ -272,39 +314,6 @@
</div>
</div>
<div class="settings-card">
<div class="card-title">{{ $t('emailPush') }}</div>
<div class="card-content">
<div class="setting-item">
<div><span>{{ $t('tgBot') }}</span></div>
<div class="forward">
<span>{{ setting.tgBotStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openTgSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('otherEmail') }}</span></div>
<div class="forward">
<span>{{ setting.forwardStatus === 0 ? $t('enabled') : $t('disabled') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openThirdEmailSetting">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
<div class="setting-item">
<div><span>{{ $t('forwardingRules') }}</span></div>
<div class="forward">
<span>{{ setting.ruleType === 0 ? $t('forwardAll') : $t('rules') }}</span>
<el-button class="opt-button" size="small" type="primary" @click="openForwardRules">
<Icon icon="fluent:settings-48-regular" width="18" height="18"/>
</el-button>
</div>
</div>
</div>
</div>
<!-- Turnstile Verification Card -->
<div class="settings-card">
<div class="card-title">{{ $t('turnstileSetting') }}</div>
@@ -659,6 +668,48 @@
</div>
</template>
</el-dialog>
<el-dialog
v-model="webhookShow"
class="forward-dialog"
@closed="webhookFormatShow = false"
>
<template #header>
<div class="forward-head">
<span class="forward-set-title">{{ $t('webhook') }}</span>
<el-tooltip effect="dark" :content="$t('webhookDesc')">
<Icon class="warning" icon="fe:warning" width="18" height="18"/>
</el-tooltip>
</div>
</template>
<div class="forward-set-body">
<el-input :placeholder="$t('webhookUrl')" v-model="webhookUrl" @keyup.enter="webhookSave"></el-input>
<el-input :placeholder="$t('webhookSecret')" v-model="webhookSecret" @keyup.enter="webhookSave"></el-input>
<div class="tg-msg-label">
<span>{{ t('webhookRetry') }}</span>
<el-input-number v-model="webhookRetry" :min="0" :max="5" controls-position="right"/>
</div>
<div v-show="webhookFormatShow" class="webhook-format">
<pre>Content-Type: application/json
Authorization: &lt;secret&gt;</pre>
<pre>{{ webhookPayloadExample }}</pre>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-switch v-model="webhookStatus" :active-value="0" :inactive-value="1" :active-text="$t('enable')"
:inactive-text="$t('disable')"/>
<div class="webhook-footer-right">
<div class="webhook-format-title" @click="webhookFormatShow = !webhookFormatShow">
<span>{{ $t('webhookFormat') }}</span>
<Icon class="webhook-format-icon" :class="{ open: webhookFormatShow }" icon="mingcute:down-small-fill" width="18" height="18"/>
</div>
<el-button :loading="settingLoading" type="primary" @click="webhookSave">
{{ $t('save') }}
</el-button>
</div>
</div>
</template>
</el-dialog>
<el-dialog
v-model="forwardRulesShow"
class="forward-dialog"
@@ -888,10 +939,10 @@ import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js";
import {useAccountStore} from "@/store/account.js";
import {Icon} from "@iconify/vue";
import {cvtR2Url} from "@/utils/convert.js";
import {cvtR2Url, toOssDomain} from "@/utils/convert.js";
import {storeToRefs} from "pinia";
import {debounce} from 'lodash-es'
import {isDomain, isEmail} from "@/utils/verify-utils.js";
import {isDomain, isEmail, isIpUrl} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js";
import {fileToBase64} from "@/utils/file-utils.js"
@@ -922,6 +973,7 @@ const turnstileShow = ref(false)
const tgSettingShow = ref(false)
const noticePopupShow = ref(false)
const thirdEmailShow = ref(false)
const webhookShow = ref(false)
const forwardRulesShow = ref(false)
const emailPrefixShow = ref(false)
const showResendList = ref(false)
@@ -1017,6 +1069,23 @@ const tgBotStatus = ref(0)
const tgBotToken = ref('')
const forwardEmail = ref([])
const forwardStatus = ref(0)
const webhookUrl = ref('')
const webhookStatus = ref(1)
const webhookRetry = ref(0)
const webhookSecret = ref('')
const webhookFormatShow = ref(false)
const webhookPayloadExample = `{
"emailId": 1,
"sendEmail": "[email protected]",
"sendName": "hello",
"toEmail": "[email protected]",
"toName": "admin",
"subject": "Hello",
"text": "Hello",
"content": "<div>Hello</div>",
"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);
}
}
+4
View File
@@ -112,6 +112,10 @@ export const settingConst = {
OPEN: 0,
CLOSE: 1,
},
webhookStatus: {
OPEN: 0,
CLOSE: 1,
},
ruleType: {
ALL: 0,
RULE: 1
+10
View File
@@ -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
+5 -1
View File
@@ -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
+7 -3
View File
@@ -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数据库未绑定<br/>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数据库未绑定<br/>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数据库未绑定<br/>D1 database not bound',502));
}
if (err.message?.includes('D1_ERROR: no such column')) {
return c.json(result.fail('请按照文档更新数据库<br/>Please update the database as documented',502));
}
return c.json(result.fail(err.message, err.code));
+1
View File
@@ -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',
+1
View File
@@ -3,6 +3,7 @@ const zh = {
addAccountDisabled: '添加邮箱功能已关闭',
regDisabled: '注册功能已关闭',
emptyEmail: '邮箱不能为空',
emptyAccountId: 'accountId不能为空',
notEmail: '非法邮箱',
notExistDomain: '不存在的邮箱域名',
isDelAccount: '该邮箱已被注销',
+11
View File
@@ -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) {
+4
View File
@@ -42,6 +42,10 @@ const emailService = {
type = 0;
}
if (isNaN(accountId)) {
throw new BizError(t('emptyAccountId'));
}
if (isNaN(size)) {
size = 10;
}
@@ -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();
@@ -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;