mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-06 07:57:04 +08:00
feat: 支持创建邮箱 API 的子域名后缀匹配开关 (#929)
* feat: 支持创建邮箱 API 的子域名后缀匹配开关 * fix: 修复 review 提到的开关三态与域名校验问题 * fix: 补充域名归一化与子域名匹配回归测试 * fix: 修复后台开关跟随 env 回退与 account_settings 半成功保存 * fix: 收口账号设置刷新提示与子域名状态重复读取 * fix: 拦截超长域名并透传账号设置刷新失败
This commit is contained in:
@@ -3,7 +3,7 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
@@ -21,6 +21,46 @@ import e2e_test_api from './e2e_test_api'
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
const normalizeAddressCreationSettingsUpdate = (
|
||||
value: unknown
|
||||
): {
|
||||
shouldUpdate: boolean,
|
||||
shouldClear: boolean,
|
||||
nextEnableSubdomainMatch?: boolean,
|
||||
} | null => {
|
||||
if (typeof value === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
|
||||
if (typeof nextEnableSubdomainMatch === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
// null 代表“清空后台覆盖,恢复为未设置并回退到 env”,这是给前端三态显式使用的正式路径。
|
||||
if (nextEnableSubdomainMatch === null) {
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: true,
|
||||
};
|
||||
}
|
||||
if (typeof nextEnableSubdomainMatch !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: false,
|
||||
nextEnableSubdomainMatch,
|
||||
};
|
||||
}
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
const allowedSortColumns: Record<string, string> = {
|
||||
@@ -294,13 +334,19 @@ api.get('/admin/account_settings', async (c) => {
|
||||
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
const addressCreationSettings = await getAddressCreationSettings(c);
|
||||
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||
emailRuleSettings: emailRuleSettings || {}
|
||||
emailRuleSettings: emailRuleSettings || {},
|
||||
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||
: {},
|
||||
addressCreationSubdomainMatchStatus,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -313,14 +359,22 @@ api.post('/admin/account_settings', async (c) => {
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
|
||||
if (!addressCreationSettingsUpdate) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text(msgs.EnableSendMailMsg, 400)
|
||||
}
|
||||
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||
JSON.stringify(blockList)
|
||||
@@ -333,9 +387,6 @@ api.post('/admin/account_settings', async (c) => {
|
||||
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(verifiedAddressList)
|
||||
)
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
||||
}
|
||||
@@ -347,6 +398,20 @@ api.post('/admin/account_settings', async (c) => {
|
||||
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
|
||||
JSON.stringify(emailRuleSettings || {})
|
||||
)
|
||||
if (addressCreationSettingsUpdate.shouldUpdate) {
|
||||
if (addressCreationSettingsUpdate.shouldClear) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM settings WHERE key = ?`
|
||||
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
|
||||
} else {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ export default {
|
||||
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
|
||||
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
|
||||
"DOMAINS": utils.getDomains(c),
|
||||
"ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH": utils.getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH),
|
||||
"RANDOM_SUBDOMAIN_DOMAINS": utils.getRandomSubdomainDomains(c),
|
||||
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
|
||||
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
|
||||
|
||||
+116
-5
@@ -5,12 +5,26 @@ import { WorkerMailerOptions } from 'worker-mailer';
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
import i18n from './i18n';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5;
|
||||
const MAX_DOMAIN_LENGTH = 253;
|
||||
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
const normalizeDomainValue = (domain: string): string => {
|
||||
return domain.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const isValidDomainLabel = (label: string): boolean => {
|
||||
return DOMAIN_LABEL_RE.test(label);
|
||||
}
|
||||
|
||||
const areValidDomainLabels = (labels: string[]): boolean => {
|
||||
return labels.length > 0 && labels.every((label) => isValidDomainLabel(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if send mail is enabled for a specific domain
|
||||
@@ -85,7 +99,98 @@ const allowRandomSubdomainForDomain = (
|
||||
c: Context<HonoCustomType>,
|
||||
domain: string
|
||||
): boolean => {
|
||||
return getRandomSubdomainDomains(c).includes(domain);
|
||||
const normalizedDomain = normalizeDomainValue(domain);
|
||||
return getRandomSubdomainDomains(c)
|
||||
.map((item) => normalizeDomainValue(item))
|
||||
.includes(normalizedDomain);
|
||||
}
|
||||
|
||||
const isCreateAddressSubdomainMatchEnvConfigured = (c: Context<HonoCustomType>): boolean => {
|
||||
return c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== undefined
|
||||
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== null
|
||||
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== "";
|
||||
}
|
||||
|
||||
export const getAddressCreationSettings = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<AddressCreationSettings> => {
|
||||
const value = await getJsonSetting<AddressCreationSettings>(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY
|
||||
);
|
||||
return new AddressCreationSettings(value);
|
||||
}
|
||||
|
||||
export const getAddressCreationSubdomainMatchStatus = async (
|
||||
c: Context<HonoCustomType>,
|
||||
existingSettings?: AddressCreationSettings
|
||||
): Promise<{
|
||||
envConfigured: boolean,
|
||||
envEnabled: boolean,
|
||||
storedEnabled: boolean | undefined,
|
||||
effectiveEnabled: boolean,
|
||||
}> => {
|
||||
const envConfigured = isCreateAddressSubdomainMatchEnvConfigured(c);
|
||||
const envEnabled = getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH);
|
||||
const addressCreationSettings = existingSettings || await getAddressCreationSettings(c);
|
||||
const storedEnabled = addressCreationSettings.enableSubdomainMatch;
|
||||
|
||||
// 业务约束:env=false 作为全局 kill switch,后台开关不能强行打开。
|
||||
const effectiveEnabled = envConfigured && !envEnabled
|
||||
? false
|
||||
: typeof storedEnabled === "boolean"
|
||||
? storedEnabled
|
||||
: envEnabled;
|
||||
|
||||
return {
|
||||
envConfigured,
|
||||
envEnabled,
|
||||
storedEnabled,
|
||||
effectiveEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const findMatchedAllowedDomain = (
|
||||
domain: string,
|
||||
allowDomains: string[],
|
||||
enableSubdomainMatch: boolean,
|
||||
): string | null => {
|
||||
const normalizedDomain = normalizeDomainValue(domain);
|
||||
if (normalizedDomain.length > MAX_DOMAIN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const domainLabels = normalizedDomain.split('.');
|
||||
if (!areValidDomainLabels(domainLabels)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedAllowDomains = allowDomains.map((allowDomain) => normalizeDomainValue(allowDomain));
|
||||
if (normalizedAllowDomains.includes(normalizedDomain)) {
|
||||
return normalizedDomain;
|
||||
}
|
||||
if (!enableSubdomainMatch) {
|
||||
return null;
|
||||
}
|
||||
const matchedDomain = [...normalizedAllowDomains]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((allowDomain) => {
|
||||
if (allowDomain.length > MAX_DOMAIN_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const allowDomainLabels = allowDomain.split('.');
|
||||
if (!areValidDomainLabels(allowDomainLabels)) {
|
||||
return false;
|
||||
}
|
||||
if (domainLabels.length <= allowDomainLabels.length) {
|
||||
return false;
|
||||
}
|
||||
const prefixLabels = domainLabels.slice(0, domainLabels.length - allowDomainLabels.length);
|
||||
if (!areValidDomainLabels(prefixLabels)) {
|
||||
return false;
|
||||
}
|
||||
return allowDomainLabels.every((label, index) => {
|
||||
return domainLabels[domainLabels.length - allowDomainLabels.length + index] === label;
|
||||
});
|
||||
});
|
||||
return matchedDomain || null;
|
||||
}
|
||||
|
||||
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
|
||||
@@ -259,13 +364,19 @@ export const newAddress = async (
|
||||
if (!domain && allowDomains.length > 0) {
|
||||
const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
|
||||
if (createAddressDefaultDomainFirst) {
|
||||
domain = allowDomains[0];
|
||||
domain = normalizeDomainValue(allowDomains[0]);
|
||||
} else {
|
||||
domain = allowDomains[Math.floor(Math.random() * allowDomains.length)];
|
||||
domain = normalizeDomainValue(allowDomains[Math.floor(Math.random() * allowDomains.length)]);
|
||||
}
|
||||
} else if (typeof domain === "string") {
|
||||
domain = normalizeDomainValue(domain);
|
||||
}
|
||||
const { effectiveEnabled: enableSubdomainMatch } = await getAddressCreationSubdomainMatchStatus(c);
|
||||
const matchedAllowDomain = domain
|
||||
? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch)
|
||||
: null;
|
||||
// check domain is valid
|
||||
if (!domain || !allowDomains.includes(domain)) {
|
||||
if (!domain || !matchedAllowDomain) {
|
||||
throw new Error(msgs.InvalidDomainMsg)
|
||||
}
|
||||
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CONSTANTS = {
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
ADDRESS_CREATION_SETTINGS_KEY: 'address_creation_settings',
|
||||
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
|
||||
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
||||
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
|
||||
|
||||
@@ -119,6 +119,16 @@ export class UserSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export class AddressCreationSettings {
|
||||
|
||||
enableSubdomainMatch: boolean | undefined;
|
||||
|
||||
constructor(data: AddressCreationSettings | undefined | null) {
|
||||
const { enableSubdomainMatch } = data || {};
|
||||
this.enableSubdomainMatch = enableSubdomainMatch;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfo {
|
||||
|
||||
geoData: GeoData;
|
||||
|
||||
Vendored
+1
@@ -25,6 +25,7 @@ type Bindings = {
|
||||
MAX_ADDRESS_LEN: string | number | undefined
|
||||
DEFAULT_DOMAINS: string | string[] | undefined
|
||||
DOMAINS: string | string[] | undefined
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH: string | boolean | undefined
|
||||
RANDOM_SUBDOMAIN_DOMAINS: string | string[] | undefined
|
||||
RANDOM_SUBDOMAIN_LENGTH: string | number | undefined
|
||||
DISABLE_CUSTOM_ADDRESS_NAME: string | boolean | undefined
|
||||
|
||||
@@ -50,6 +50,9 @@ PREFIX = "tmp"
|
||||
# CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = false
|
||||
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
|
||||
# Allow /api/new_address and /admin/new_address to accept subdomains that end with an allowed base domain
|
||||
# e.g. if DOMAINS contains "abc.com", API can accept "team.abc.com" and "dev.team.abc.com"
|
||||
# ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
# Allow optional random subdomain generation for the listed base domains
|
||||
# e.g. name@abc.com => name@r4nd0m.abc.com
|
||||
# RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
|
||||
Reference in New Issue
Block a user