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:
Dream Hunter
2025-12-29 17:15:24 +08:00
committed by GitHub
parent 3b3968f3b4
commit 5e227d2b2d
6 changed files with 247 additions and 48 deletions

139
worker/src/email/forward.ts Normal file
View 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,
};

View File

@@ -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 {

View File

@@ -144,4 +144,7 @@ type ParsedEmailContext = {
type SubdomainForwardAddressList = {
domains: string[] | undefined | null,
forward: string,
// 来源地址正则匹配 (可选,兼容原配置)
sourcePatterns?: string[] | undefined | null, // 来源地址正则表达式列表
sourceMatchMode?: 'any' | 'all' | undefined, // 匹配模式: any-任一匹配, all-全部匹配
}