feat: add email blacklist filtering

This commit is contained in:
eoao
2026-05-08 22:40:17 +08:00
parent 154f536c61
commit b60add170c
9 changed files with 166 additions and 8 deletions

View File

@@ -147,6 +147,7 @@ const en = {
autoRefreshDesc: 'Automatically fetch the latest emails from the server', autoRefreshDesc: 'Automatically fetch the latest emails from the server',
sendEmail: 'Send Email', sendEmail: 'Send Email',
resendToken: 'Resend Token', resendToken: 'Resend Token',
blackList: 'Blocklist',
oss: 'Object Storage', oss: 'Object Storage',
osDomain: 'Domain', osDomain: 'Domain',
ossDomainDesc: 'Leave empty if using KV storage.', ossDomainDesc: 'Leave empty if using KV storage.',
@@ -311,6 +312,9 @@ const en = {
character: '', character: '',
mustNotContain: 'Must Not Contain', mustNotContain: 'Must Not Contain',
mustNotContainDesc: 'Separate with commas', mustNotContainDesc: 'Separate with commas',
blackSubjectDesc: '输入多个值用,分开',
blackContentDesc: '输入多个值用,分开',
blackFromDesc: '输入多个值用,分开',
setSuccess: 'Settings saved successfully', setSuccess: 'Settings saved successfully',
details: 'Details', details: 'Details',
userDetails: 'User Details', userDetails: 'User Details',

View File

@@ -147,6 +147,7 @@ const zh = {
autoRefreshDesc: '轮询请求服务器获取最新邮件', autoRefreshDesc: '轮询请求服务器获取最新邮件',
sendEmail: '邮件发送', sendEmail: '邮件发送',
resendToken: 'Resend Token', resendToken: 'Resend Token',
blackList: '邮件黑名单',
oss: '对象存储', oss: '对象存储',
osDomain: '访问域名', osDomain: '访问域名',
ossDomainDesc: '如果是KV存储不要填', ossDomainDesc: '如果是KV存储不要填',
@@ -311,6 +312,9 @@ const zh = {
character: '位', character: '位',
mustNotContain: '禁止包含', mustNotContain: '禁止包含',
mustNotContainDesc: '输入多个值用,分开', mustNotContainDesc: '输入多个值用,分开',
blackSubjectDesc: '邮件标题',
blackContentDesc: '邮件内容',
blackFromDesc: '发件人邮箱或域名',
setSuccess: '设置成功', setSuccess: '设置成功',
details: '详情', details: '详情',
userDetails: '用户详情', userDetails: '用户详情',

View File

@@ -1,7 +1,7 @@
import http from '@/axios/index.js'; import http from '@/axios/index.js';
export function settingSet(setting) { export function settingSet(setting) {
return http.put('/setting/set',setting) return http.put('/setting/set', setting)
} }
export function settingQuery() { export function settingQuery() {
@@ -19,3 +19,7 @@ export function setBackground(background) {
export function deleteBackground() { export function deleteBackground() {
return http.delete('/setting/deleteBackground') return http.delete('/setting/deleteBackground')
} }
export function setBlackList(params) {
return http.put('/setting/setBlacklist', params)
}

View File

@@ -189,6 +189,15 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="setting-item">
<div><span>{{ $t('blackList') }}</span></div>
<div>
<el-button class="opt-button" style="margin-top: 0" @click="openBlackListForm" size="small"
type="primary">
<Icon icon="fluent:settings-48-regular" width="16" height="16"/>
</el-button>
</div>
</div>
</div> </div>
</div> </div>
@@ -724,17 +733,31 @@
</div> </div>
<div class="prefix-filter"> <div class="prefix-filter">
<div style="margin-bottom: 10px;">{{ t('mustNotContain') }}</div> <div style="margin-bottom: 10px;">{{ t('mustNotContain') }}</div>
<el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" :placeholder="t('mustNotContainDesc')" /> <el-input-tag style="margin-bottom: 10px;" v-model="emailPrefixFilter" />
</div> </div>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveEmailPrefix">{{ $t('save') }}</el-button> <el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveEmailPrefix">{{ $t('save') }}</el-button>
</el-dialog> </el-dialog>
<el-dialog v-model="blackFormShow" :title="t('blackList')" class="forward-dialog" @closed="resetBlackList">
<el-form>
<el-form-item :label="t('blackFromDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackFrom" @add-tag="banEmailAddTag" />
</el-form-item>
<el-form-item :label="t('blackSubjectDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackSubject"/>
</el-form-item>
<el-form-item :label="t('blackContentDesc')" label-position="top">
<el-input-tag v-model="blackListForm.blackContent"/>
</el-form-item>
</el-form>
<el-button type="primary" style="width: 100%;" :loading="settingLoading" @click="saveBlackList">{{ $t('save') }}</el-button>
</el-dialog>
</el-scrollbar> </el-scrollbar>
</div> </div>
</template> </template>
<script setup> <script setup>
import {computed, defineOptions, reactive, ref} from "vue"; import {computed, defineOptions, reactive, ref} from "vue";
import {deleteBackground, setBackground, settingQuery, settingSet} from "@/request/setting.js"; import {deleteBackground, setBackground, setBlackList, settingQuery, settingSet} from "@/request/setting.js";
import {useSettingStore} from "@/store/setting.js"; import {useSettingStore} from "@/store/setting.js";
import {useUiStore} from "@/store/ui.js"; import {useUiStore} from "@/store/ui.js";
import {useUserStore} from "@/store/user.js"; import {useUserStore} from "@/store/user.js";
@@ -743,7 +766,7 @@ import {Icon} from "@iconify/vue";
import {cvtR2Url} from "@/utils/convert.js"; import {cvtR2Url} from "@/utils/convert.js";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {debounce} from 'lodash-es' import {debounce} from 'lodash-es'
import {isEmail} from "@/utils/verify-utils.js"; import {isDomain, isEmail} from "@/utils/verify-utils.js";
import loading from "@/components/loading/index.vue"; import loading from "@/components/loading/index.vue";
import {getTextWidth} from "@/utils/text.js"; import {getTextWidth} from "@/utils/text.js";
import {fileToBase64} from "@/utils/file-utils.js" import {fileToBase64} from "@/utils/file-utils.js"
@@ -765,6 +788,7 @@ const accountStore = useAccountStore();
const userStore = useUserStore(); const userStore = useUserStore();
const editTitleShow = ref(false) const editTitleShow = ref(false)
const resendTokenFormShow = ref(false) const resendTokenFormShow = ref(false)
const blackFormShow = ref(false)
const r2DomainShow = ref(false) const r2DomainShow = ref(false)
const turnstileShow = ref(false) const turnstileShow = ref(false)
const tgSettingShow = ref(false) const tgSettingShow = ref(false)
@@ -827,6 +851,12 @@ const regKeyOptions = computed(() => [
{label: t('optional'), value: 2}, {label: t('optional'), value: 2},
]) ])
const blackListForm = ref({
blackSubject: [],
blackContent: [],
blackFrom: []
})
const authRefreshOptions = computed(() => [ const authRefreshOptions = computed(() => [
{label: t('disable'), value: 0}, {label: t('disable'), value: 0},
{label: '3s', value: 3}, {label: '3s', value: 3},
@@ -874,6 +904,7 @@ function getSettings() {
resetNoticeForm() resetNoticeForm()
resetAddS3Form() resetAddS3Form()
resetEmailPrefix() resetEmailPrefix()
resetBlackList()
}) })
} }
@@ -1148,6 +1179,11 @@ function resetEmailPrefix() {
emailPrefixFilter.value = setting.value.emailPrefixFilter emailPrefixFilter.value = setting.value.emailPrefixFilter
} }
function resetBlackList() {
blackListForm.value.blackFrom = setting.value.blackFrom ? setting.value.blackFrom.split(',') : []
blackListForm.value.blackContent = setting.value.blackContent ? setting.value.blackContent.split(',') : []
blackListForm.value.blackSubject = setting.value.blackSubject ? setting.value.blackSubject.split(',') : []
}
function saveEmailPrefix() { function saveEmailPrefix() {
const form = {} const form = {}
form.minEmailPrefix = minEmailPrefix.value form.minEmailPrefix = minEmailPrefix.value
@@ -1160,6 +1196,44 @@ const opacityChange = debounce(doOpacityChange, 1000, {
trailing: true trailing: true
}) })
function saveBlackList() {
let form = {
blackContent: blackListForm.value.blackContent + '',
blackSubject: blackListForm.value.blackSubject + '',
blackFrom: blackListForm.value.blackFrom + ''
}
settingLoading.value = true
setBlackList(form).then(() => {
getSettings()
ElMessage({
message: t('setSuccess'),
type: "success",
plain: true
})
blackFormShow.value = false;
}).finally(() => {
settingLoading.value = false;
})
}
function banEmailAddTag(val) {
const emails = Array.from(new Set(
val.split(/[,]/).map(item => item.trim()).filter(item => item)
));
blackListForm.value.blackFrom.splice(blackListForm.value.blackFrom.length - 1, 1)
emails.forEach(email => {
if ((isEmail(email) || isDomain(email)) && !blackListForm.value.blackFrom.includes(email)) {
blackListForm.value.blackFrom.push(email)
}
})
}
function delBackground() { function delBackground() {
ElMessageBox.confirm(t('delBackgroundConfirm'), { ElMessageBox.confirm(t('delBackgroundConfirm'), {
confirmButtonText: t('confirm'), confirmButtonText: t('confirm'),
@@ -1245,6 +1319,10 @@ function openResendForm() {
resendTokenFormShow.value = true resendTokenFormShow.value = true
} }
function openBlackListForm() {
blackFormShow.value = true
}
function saveResendToken() { function saveResendToken() {
const settingForm = { const settingForm = {
resendTokens: {} resendTokens: {}

View File

@@ -27,3 +27,8 @@ app.delete('/setting/deleteBackground', async (c) => {
return c.json(result.ok()); return c.json(result.ok());
}); });
app.put('/setting/setBlacklist', async (c) => {
const setting = await settingService.setBlacklist(c, await c.req.json());
return c.json(result.ok(setting));
})

View File

@@ -24,7 +24,10 @@ export async function email(message, env, ctx) {
ruleEmail, ruleEmail,
ruleType, ruleType,
r2Domain, r2Domain,
noRecipient noRecipient,
blackSubject,
blackContent,
blackFrom
} = await settingService.query({ env }); } = await settingService.query({ env });
if (receive === settingConst.receive.CLOSE) { if (receive === settingConst.receive.CLOSE) {
@@ -32,7 +35,6 @@ export async function email(message, env, ctx) {
return; return;
} }
const reader = message.raw.getReader(); const reader = message.raw.getReader();
let content = ''; let content = '';
@@ -44,6 +46,14 @@ export async function email(message, env, ctx) {
const email = await PostalMime.parse(content); const email = await PostalMime.parse(content);
const blockFlag = checkBlock(blackSubject, blackContent, blackFrom, email);
if (blockFlag) {
message.setReject('Message rejected');
return;
}
const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to); const account = await accountService.selectByEmailIncludeDel({ env: env }, message.to);
if (!account && noRecipient === settingConst.noRecipient.CLOSE) { if (!account && noRecipient === settingConst.noRecipient.CLOSE) {
@@ -169,3 +179,31 @@ export async function email(message, env, ctx) {
throw e throw e
} }
} }
function checkBlock(blackSubjectStr, blackContentStr, blackFromStr, email) {
const blackFromList = blackFromStr ? blackFromStr.split(',') : []
const blackContentList = blackContentStr ? blackContentStr.split(',') : []
const blackSubjectList = blackSubjectStr ? blackSubjectStr.split(',') : []
for (const blackSubject of blackSubjectList) {
if (email.subject?.includes(blackSubject)) {
return true
}
}
for (const blackContent of blackContentList) {
if (email.html?.includes(blackContent) || email.text?.includes(blackContent)) {
return true
}
}
for (const blackFrom of blackFromList) {
if (email.from.address.includes(blackFrom)) {
return true
}
}
return false
}

View File

@@ -46,6 +46,9 @@ export const setting = sqliteTable('setting', {
tgMsgTo: text('tg_msg_to').default('show').notNull(), tgMsgTo: text('tg_msg_to').default('show').notNull(),
tgMsgText: text('tg_msg_text').default('hide').notNull(), tgMsgText: text('tg_msg_text').default('hide').notNull(),
minEmailPrefix: integer('min_email_prefix').default(0).notNull(), minEmailPrefix: integer('min_email_prefix').default(0).notNull(),
emailPrefixFilter: text('email_prefix_filter').default('').notNull() emailPrefixFilter: text('email_prefix_filter').default('').notNull(),
blackSubject: text('black_subject').default('').notNull(),
blackContent: text('black_content').default('').notNull(),
blackFrom: text('black_from').default('').notNull()
}); });
export default setting export default setting

View File

@@ -28,10 +28,23 @@ const dbInit = {
await this.v2_7DB(c); await this.v2_7DB(c);
await this.v2_8DB(c); await this.v2_8DB(c);
await this.v2_9DB(c); await this.v2_9DB(c);
await this.v3_0DB(c);
await settingService.refresh(c); await settingService.refresh(c);
return c.text('success'); return c.text('success');
}, },
async v3_0DB(c) {
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_subject TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_content TEXT NOT NULL DEFAULT '';`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN black_from TEXT NOT NULL DEFAULT '';`)
]);
} catch (e) {
console.warn(`跳过字段:${e.message}`);
}
},
async v2_9DB(c) { async v2_9DB(c) {
try { try {
await c.env.db.prepare(`UPDATE setting SET auto_refresh = 5 WHERE auto_refresh = 1;`).run(); await c.env.db.prepare(`UPDATE setting SET auto_refresh = 5 WHERE auto_refresh = 1;`).run();

View File

@@ -184,6 +184,14 @@ const settingService = {
return background; return background;
}, },
async setBlacklist(c, params) {
const { blackSubject, blackContent, blackFrom } = params
await orm(c).update(setting).set({ blackSubject, blackContent, blackFrom }).run();
await this.refresh(c);
return this.get(c);
},
async websiteConfig(c) { async websiteConfig(c) {
const settingRow = await this.get(c, true); const settingRow = await this.get(c, true);
@@ -220,7 +228,8 @@ const settingService = {
minEmailPrefix: settingRow.minEmailPrefix, minEmailPrefix: settingRow.minEmailPrefix,
projectLink: settingRow.projectLink projectLink: settingRow.projectLink
}; };
} },
}; };
export default settingService; export default settingService;