mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-06 20:32:55 +08:00
feat: add source address regex forwarding (#796)
feat: add source address regex forwarding for email rules - Add sourcePatterns field to filter forwarding by sender address regex - Support 'any' and 'all' match modes for multiple patterns - Add ReDoS protection with 200 character limit - Frontend validation for regex patterns - Fully backward compatible with existing configurations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
|
||||
- feat: |地址来源| 新增地址来源追踪功能,记录地址创建来源(Web 记录 IP,Telegram 记录用户 ID,Admin 后台标记)
|
||||
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
|
||||
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
|
||||
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
|
||||
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
|
||||
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
|
||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
@@ -24,7 +24,7 @@ const { t } = useI18n({
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
block_receive_unknow_address_email: 'Block receive unknow address email',
|
||||
email_forwarding_config: 'Email Forwarding Configuration',
|
||||
domain_list: 'Domain List',
|
||||
domain_list: 'Domain List (Optional)',
|
||||
forward_address: 'Forward Address',
|
||||
actions: 'Actions',
|
||||
select_domain: 'Select Domain',
|
||||
@@ -32,10 +32,20 @@ const { t } = useI18n({
|
||||
delete_rule: 'Delete',
|
||||
delete_rule_confirm: 'Are you sure you want to delete this rule?',
|
||||
delete_success: 'Delete Success',
|
||||
forwarding_rule_warning: 'Each rule will run, if domains is empty, all emails will be forwarded, forward address needs to be a verified address',
|
||||
forwarding_rule_warning: 'Each rule will run independently. Forward address needs to be a verified address.',
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
config: 'Config',
|
||||
source_patterns: 'Source Address Regex (Optional)',
|
||||
source_patterns_placeholder: 'e.g. @gmail\\.com$',
|
||||
source_match_mode: 'Match Mode',
|
||||
match_any: 'Any',
|
||||
match_all: 'All',
|
||||
source_patterns_tip: 'Domain list filters by recipient address, source regex filters by sender address. Both conditions must match for forwarding (AND logic). Leave either empty to skip that filter.',
|
||||
regex_too_long: 'Regex pattern too long (max 200 characters)',
|
||||
regex_invalid: 'Invalid regex pattern',
|
||||
forward_address_required: 'Forward address is required',
|
||||
rule_index: 'Rule',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
@@ -50,7 +60,7 @@ const { t } = useI18n({
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
block_receive_unknow_address_email: '禁止接收未知地址邮件',
|
||||
email_forwarding_config: '邮件转发配置',
|
||||
domain_list: '域名列表',
|
||||
domain_list: '域名列表(可选)',
|
||||
forward_address: '转发地址',
|
||||
actions: '操作',
|
||||
select_domain: '选择域名',
|
||||
@@ -58,10 +68,20 @@ const { t } = useI18n({
|
||||
delete_rule: '删除',
|
||||
delete_rule_confirm: '确定要删除这条规则吗?',
|
||||
delete_success: '删除成功',
|
||||
forwarding_rule_warning: '每条规则都会运行,如果 domains 为空,则转发所有邮件,转发地址需要为已验证的地址',
|
||||
forwarding_rule_warning: '每条规则独立运行,转发地址需要为已验证的地址。',
|
||||
add: '添加',
|
||||
cancel: '取消',
|
||||
config: '配置',
|
||||
source_patterns: '来源地址正则(可选)',
|
||||
source_patterns_placeholder: '例如: @gmail\\.com$',
|
||||
source_match_mode: '匹配模式',
|
||||
match_any: '任一',
|
||||
match_all: '全部',
|
||||
source_patterns_tip: '域名列表按收件地址过滤,来源正则按发件地址过滤,两者均为可选。同时配置时需同时满足(AND 逻辑),留空则跳过该条件。',
|
||||
regex_too_long: '正则表达式过长(最大200字符)',
|
||||
regex_invalid: '无效的正则表达式',
|
||||
forward_address_required: '转发地址不能为空',
|
||||
rule_index: '规则',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -98,6 +118,39 @@ const emailForwardingColumns = [
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('source_patterns'),
|
||||
key: 'sourcePatterns',
|
||||
render: (row, index) => {
|
||||
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' }, [
|
||||
h(NSelect, {
|
||||
value: Array.isArray(row.sourcePatterns) ? row.sourcePatterns : [],
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].sourcePatterns = val
|
||||
},
|
||||
multiple: true,
|
||||
filterable: true,
|
||||
tag: true,
|
||||
placeholder: t('source_patterns_placeholder')
|
||||
}, {
|
||||
empty: () => h('span', { style: 'color: #999; font-size: 12px;' }, t('manualInputPrompt'))
|
||||
}),
|
||||
h(NRadioGroup, {
|
||||
value: row.sourceMatchMode || 'any',
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].sourceMatchMode = val
|
||||
},
|
||||
size: 'small',
|
||||
style: 'margin-top: 4px;'
|
||||
}, {
|
||||
default: () => [
|
||||
h(NRadio, { value: 'any' }, { default: () => t('match_any') }),
|
||||
h(NRadio, { value: 'all' }, { default: () => t('match_all') })
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('forward_address'),
|
||||
key: 'forward',
|
||||
@@ -145,12 +198,50 @@ const addNewEmailForwardingItem = () => {
|
||||
...emailForwardingList.value,
|
||||
{
|
||||
domains: [],
|
||||
forward: ''
|
||||
forward: '',
|
||||
sourcePatterns: [],
|
||||
sourceMatchMode: 'any'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MAX_REGEX_LENGTH = 200
|
||||
|
||||
const validateForwardingRules = () => {
|
||||
for (let i = 0; i < emailForwardingList.value.length; i++) {
|
||||
const rule = emailForwardingList.value[i]
|
||||
|
||||
// 验证转发地址
|
||||
if (!rule.forward || rule.forward.trim() === '') {
|
||||
message.error(`${t('forward_address_required')} (${t('rule_index')} ${i + 1})`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证正则表达式
|
||||
if (rule.sourcePatterns && rule.sourcePatterns.length > 0) {
|
||||
for (const pattern of rule.sourcePatterns) {
|
||||
// 检查长度
|
||||
if (pattern.length > MAX_REGEX_LENGTH) {
|
||||
message.error(`${t('regex_too_long')}: ${pattern.substring(0, 30)}...`)
|
||||
return false
|
||||
}
|
||||
// 检查正则有效性
|
||||
try {
|
||||
new RegExp(pattern, 'i')
|
||||
} catch (e) {
|
||||
message.error(`${t('regex_invalid')}: ${pattern}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const saveEmailForwardingConfig = () => {
|
||||
if (!validateForwardingRules()) {
|
||||
return
|
||||
}
|
||||
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
@@ -269,10 +360,12 @@ onMounted(async () => {
|
||||
|
||||
<!-- 邮件转发配置弹窗 -->
|
||||
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
|
||||
style="max-width: 800px;">
|
||||
style="max-width: 1000px;">
|
||||
<n-space vertical>
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning">
|
||||
<span>{{ t('forwarding_rule_warning') }}</span>
|
||||
<br />
|
||||
<span>{{ t('source_patterns_tip') }}</span>
|
||||
</n-alert>
|
||||
<n-space justify="end">
|
||||
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
|
||||
|
||||
139
worker/src/email/forward.ts
Normal file
139
worker/src/email/forward.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
|
||||
import { EmailRuleSettings } from "../models";
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
// 正则表达式最大长度限制,防止 ReDoS 攻击
|
||||
const MAX_REGEX_PATTERN_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 安全地测试单个正则表达式
|
||||
*/
|
||||
function safeRegexTest(pattern: string, input: string): boolean {
|
||||
try {
|
||||
// 限制正则复杂度:最大长度限制
|
||||
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
|
||||
console.warn("source pattern too long, skipped:", pattern.substring(0, 50) + "...");
|
||||
return false;
|
||||
}
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(input);
|
||||
} catch (regexError) {
|
||||
console.error("regex test error for pattern:", pattern, regexError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查来源地址是否匹配正则规则
|
||||
*/
|
||||
function matchSourcePatterns(
|
||||
from: string,
|
||||
sourcePatterns: string[] | undefined | null,
|
||||
sourceMatchMode: 'any' | 'all' | undefined
|
||||
): boolean {
|
||||
if (!sourcePatterns || sourcePatterns.length === 0) {
|
||||
// 未配置来源正则,默认匹配
|
||||
return true;
|
||||
}
|
||||
|
||||
const matchMode = sourceMatchMode || 'any';
|
||||
|
||||
if (matchMode === 'all') {
|
||||
// 全部匹配模式:所有正则都必须匹配
|
||||
return sourcePatterns.every(pattern => safeRegexTest(pattern, from));
|
||||
} else {
|
||||
// 任一匹配模式(默认):任一正则匹配即可
|
||||
return sourcePatterns.some(pattern => safeRegexTest(pattern, from));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局转发:转发到 FORWARD_ADDRESS_LIST 中的所有地址
|
||||
*/
|
||||
async function forwardToGlobalAddresses(
|
||||
message: ForwardableEmailMessage,
|
||||
env: Bindings
|
||||
): Promise<void> {
|
||||
try {
|
||||
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST);
|
||||
for (const forwardAddress of forwardAddressList) {
|
||||
await message.forward(forwardAddress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("forward email error", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则转发:根据域名和来源地址正则规则转发
|
||||
*/
|
||||
async function forwardByRules(
|
||||
message: ForwardableEmailMessage,
|
||||
env: Bindings
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 获取环境变量配置
|
||||
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(
|
||||
env.SUBDOMAIN_FORWARD_ADDRESS_LIST
|
||||
) || [];
|
||||
|
||||
// 获取数据库配置
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
CONSTANTS.EMAIL_RULE_SETTINGS_KEY
|
||||
);
|
||||
|
||||
// 合并两个配置,env 里的配置优先级更高
|
||||
const allRules = [
|
||||
...(subdomainForwardAddressList || []),
|
||||
...(emailRuleSettings?.emailForwardingList || []),
|
||||
];
|
||||
|
||||
for (const rule of allRules) {
|
||||
// 检查来源地址是否匹配正则
|
||||
if (!matchSourcePatterns(message.from, rule.sourcePatterns, rule.sourceMatchMode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查目标地址是否匹配域名,并转发
|
||||
// 保持原始逻辑:每个匹配的 domain 都会触发一次转发
|
||||
if (rule.domains && rule.domains.length > 0) {
|
||||
for (const domain of rule.domains) {
|
||||
if (message.to.endsWith(domain) && rule.forward) {
|
||||
await message.forward(rule.forward);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 域名为空,转发所有邮件
|
||||
if (rule.forward) {
|
||||
await message.forward(rule.forward);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("forward by rules error", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行所有转发逻辑
|
||||
*/
|
||||
async function forwardEmail(
|
||||
message: ForwardableEmailMessage,
|
||||
env: Bindings
|
||||
): Promise<void> {
|
||||
// 全局转发
|
||||
await forwardToGlobalAddresses(message, env);
|
||||
|
||||
// 规则转发
|
||||
await forwardByRules(message, env);
|
||||
}
|
||||
|
||||
export {
|
||||
forwardEmail,
|
||||
forwardToGlobalAddresses,
|
||||
forwardByRules,
|
||||
matchSourcePatterns,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
|
||||
import { getJsonSetting } from "../utils";
|
||||
import { sendMailToTelegram } from "../telegram_api";
|
||||
import { auto_reply } from "./auto_reply";
|
||||
import { isBlocked } from "./black_list";
|
||||
@@ -8,6 +8,7 @@ import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common
|
||||
import { check_if_junk_mail } from "./check_junk";
|
||||
import { remove_attachment_if_need } from "./check_attachment";
|
||||
import { extractEmailInfo } from "./ai_extract";
|
||||
import { forwardEmail } from "./forward";
|
||||
import { EmailRuleSettings } from "../models";
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
@@ -79,46 +80,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
}
|
||||
|
||||
// forward email
|
||||
try {
|
||||
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST)
|
||||
for (const forwardAddress of forwardAddressList) {
|
||||
await message.forward(forwardAddress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("forward email error", error);
|
||||
}
|
||||
|
||||
// forward subdomain email
|
||||
try {
|
||||
// 遍历 FORWARD_ADDRESS_LIST
|
||||
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(env.SUBDOMAIN_FORWARD_ADDRESS_LIST) || [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
|
||||
{ env: env } as Context<HonoCustomType>, CONSTANTS.EMAIL_RULE_SETTINGS_KEY
|
||||
);
|
||||
// 合并两个配置, env 里的配置优先级更高
|
||||
const allSubdomainForwardAddressList = [
|
||||
...(subdomainForwardAddressList || []),
|
||||
...(emailRuleSettings?.emailForwardingList || []),
|
||||
];
|
||||
for (const subdomainForwardAddress of allSubdomainForwardAddressList) {
|
||||
// 检查邮件是否匹配 domains
|
||||
if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) {
|
||||
for (const domain of subdomainForwardAddress.domains) {
|
||||
if (message.to.endsWith(domain) && subdomainForwardAddress.forward) {
|
||||
// 转发邮件
|
||||
await message.forward(subdomainForwardAddress.forward);
|
||||
// 支持多邮箱转发收件,不进行截止
|
||||
// break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果 domains 为空,则转发所有邮件
|
||||
await message.forward(subdomainForwardAddress.forward);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("subdomain forward email error", error);
|
||||
}
|
||||
await forwardEmail(message, env);
|
||||
|
||||
// send email to telegram
|
||||
try {
|
||||
|
||||
3
worker/src/types.d.ts
vendored
3
worker/src/types.d.ts
vendored
@@ -144,4 +144,7 @@ type ParsedEmailContext = {
|
||||
type SubdomainForwardAddressList = {
|
||||
domains: string[] | undefined | null,
|
||||
forward: string,
|
||||
// 来源地址正则匹配 (可选,兼容原配置)
|
||||
sourcePatterns?: string[] | undefined | null, // 来源地址正则表达式列表
|
||||
sourceMatchMode?: 'any' | 'all' | undefined, // 匹配模式: any-任一匹配, all-全部匹配
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user