feat: add webhook
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -172,6 +172,13 @@ const zh = {
|
||||
disable: '关闭',
|
||||
disabled: '已关闭',
|
||||
otherEmail: '第三方邮箱',
|
||||
webhook: 'Webhook',
|
||||
webhookDesc: '收到邮件后向指定地址 POST JSON',
|
||||
webhookUrl: '请求地址',
|
||||
webhookIpNotSupported: '不能使用 IP 地址,请填写域名',
|
||||
webhookSecret: '密钥(可选)',
|
||||
webhookRetry: '重试次数',
|
||||
webhookFormat: '格式说明',
|
||||
forwardingRules: '转发规则',
|
||||
forwardAll: '全部转发',
|
||||
rules: '规则转发',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: <secret></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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ export const settingConst = {
|
||||
OPEN: 0,
|
||||
CLOSE: 1,
|
||||
},
|
||||
webhookStatus: {
|
||||
OPEN: 0,
|
||||
CLOSE: 1,
|
||||
},
|
||||
ruleType: {
|
||||
ALL: 0,
|
||||
RULE: 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,6 +3,7 @@ const zh = {
|
||||
addAccountDisabled: '添加邮箱功能已关闭',
|
||||
regDisabled: '注册功能已关闭',
|
||||
emptyEmail: '邮箱不能为空',
|
||||
emptyAccountId: 'accountId不能为空',
|
||||
notEmail: '非法邮箱',
|
||||
notExistDomain: '不存在的邮箱域名',
|
||||
isDelAccount: '该邮箱已被注销',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user