mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
feat: 支持创建邮箱 API 的子域名后缀匹配开关 (#929)
* feat: 支持创建邮箱 API 的子域名后缀匹配开关 * fix: 修复 review 提到的开关三态与域名校验问题 * fix: 补充域名归一化与子域名匹配回归测试 * fix: 修复后台开关跟随 env 回退与 account_settings 半成功保存 * fix: 收口账号设置刷新提示与子域名状态重复读取 * fix: 拦截超长域名并透传账号设置刷新失败
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
||||||
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
||||||
|
- feat: |创建邮箱| 新增 `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 开关,并支持在管理后台单独控制创建邮箱 API 的子域名后缀匹配;开启后允许 `foo.example.com` 匹配基础域名 `example.com`
|
||||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||||
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
||||||
@@ -24,10 +25,12 @@
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级
|
||||||
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
|
- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别
|
||||||
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910)
|
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910)
|
||||||
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
||||||
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
||||||
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
||||||
|
- feat: |Create Address| Add `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` switch and an admin-panel toggle for suffix-based subdomain matching in create-address APIs; when enabled, `foo.example.com` can match base domain `example.com`
|
||||||
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
||||||
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
||||||
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
|
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
|
||||||
@@ -24,10 +25,12 @@
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| Add create-address subdomain matching tests covering default exact-match behavior, admin-enabled matching, and env=false hard-disable precedence
|
||||||
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
|
- docs: |Create Address| Update create-address API, worker variables, and subdomain docs to clarify the difference between explicitly specified subdomains and random subdomains
|
||||||
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
|
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
|
||||||
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
|
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
|
||||||
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
|
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
|
||||||
|
|||||||
@@ -20,6 +20,40 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
worker-subdomain:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.worker
|
||||||
|
ports:
|
||||||
|
- "8789:8789"
|
||||||
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8789", "--ip", "0.0.0.0"]
|
||||||
|
depends_on:
|
||||||
|
- mailpit
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8789/health_check"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
worker-env-off:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.worker
|
||||||
|
ports:
|
||||||
|
- "8788:8788"
|
||||||
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8788", "--ip", "0.0.0.0"]
|
||||||
|
volumes:
|
||||||
|
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||||
|
depends_on:
|
||||||
|
- mailpit
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8788/health_check"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -73,6 +107,8 @@ services:
|
|||||||
dockerfile: e2e/Dockerfile.e2e
|
dockerfile: e2e/Dockerfile.e2e
|
||||||
environment:
|
environment:
|
||||||
WORKER_URL: http://worker:8787
|
WORKER_URL: http://worker:8787
|
||||||
|
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
|
||||||
|
WORKER_URL_ENV_OFF: http://worker-env-off:8788
|
||||||
FRONTEND_URL: https://frontend:5173
|
FRONTEND_URL: https://frontend:5173
|
||||||
MAILPIT_API: http://mailpit:8025/api
|
MAILPIT_API: http://mailpit:8025/api
|
||||||
SMTP_PROXY_HOST: smtp-proxy
|
SMTP_PROXY_HOST: smtp-proxy
|
||||||
@@ -85,6 +121,10 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
worker:
|
worker:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
worker-subdomain:
|
||||||
|
condition: service_healthy
|
||||||
|
worker-env-off:
|
||||||
|
condition: service_healthy
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
smtp-proxy:
|
smtp-proxy:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { APIRequestContext } from '@playwright/test';
|
|||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
export const WORKER_URL = process.env.WORKER_URL!;
|
export const WORKER_URL = process.env.WORKER_URL!;
|
||||||
|
export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
||||||
|
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||||
export const TEST_DOMAIN = 'test.example.com';
|
export const TEST_DOMAIN = 'test.example.com';
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name = "cloudflare_temp_email_env_off"
|
||||||
|
main = "src/worker.ts"
|
||||||
|
compatibility_date = "2025-04-01"
|
||||||
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
|
keep_vars = true
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
PREFIX = "tmp"
|
||||||
|
DEFAULT_DOMAINS = ["test.example.com"]
|
||||||
|
DOMAINS = ["test.example.com"]
|
||||||
|
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
|
||||||
|
JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||||
|
BLACK_LIST = ""
|
||||||
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
ENABLE_AUTO_REPLY = true
|
||||||
|
DEFAULT_SEND_BALANCE = 10
|
||||||
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
|
ENABLE_WEBHOOK = true
|
||||||
|
E2E_TEST_MODE = true
|
||||||
|
SMTP_CONFIG = """
|
||||||
|
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "KV"
|
||||||
|
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "e2e-temp-email-env-off"
|
||||||
|
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||||
@@ -14,6 +14,36 @@ for i in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||||
|
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
|
||||||
|
echo " Subdomain worker ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Subdomain worker not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||||
|
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
|
||||||
|
echo " Env-off worker ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Env-off worker not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||||
@@ -44,5 +74,19 @@ curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null
|
|||||||
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
|
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
|
||||||
echo " Database initialized"
|
echo " Database initialized"
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||||
|
echo "==> Initializing subdomain worker database"
|
||||||
|
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
|
||||||
|
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
|
||||||
|
echo " Subdomain worker database initialized"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||||
|
echo "==> Initializing env-off worker database"
|
||||||
|
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
|
||||||
|
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
|
||||||
|
echo " Env-off database initialized"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Running Playwright tests"
|
echo "==> Running Playwright tests"
|
||||||
exec npx playwright test "$@"
|
exec npx playwright test "$@"
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||||
|
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||||
|
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||||
|
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||||
|
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||||
|
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||||
|
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||||
|
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||||
|
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||||
|
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||||
|
|
||||||
|
async function getAccountSettings(request: any, workerUrl: string) {
|
||||||
|
const res = await request.get(`${workerUrl}/admin/account_settings`);
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccountSettingsPayload(
|
||||||
|
current: any,
|
||||||
|
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
blockList: current.blockList || [],
|
||||||
|
sendBlockList: current.sendBlockList || [],
|
||||||
|
verifiedAddressList: current.verifiedAddressList || [],
|
||||||
|
fromBlockList: current.fromBlockList || [],
|
||||||
|
noLimitSendAddressList: current.noLimitSendAddressList || [],
|
||||||
|
emailRuleSettings: current.emailRuleSettings || {},
|
||||||
|
...(typeof addressCreationSettings !== 'undefined'
|
||||||
|
? { addressCreationSettings }
|
||||||
|
: {}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSubdomainMatchSetting(
|
||||||
|
request: any,
|
||||||
|
workerUrl: string,
|
||||||
|
enableSubdomainMatch: boolean | null
|
||||||
|
) {
|
||||||
|
const current = await getAccountSettings(request, workerUrl);
|
||||||
|
const res = await request.post(`${workerUrl}/admin/account_settings`, {
|
||||||
|
data: buildAccountSettingsPayload(current, {
|
||||||
|
enableSubdomainMatch,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSubdomainMatchSetting(
|
||||||
|
request: any,
|
||||||
|
workerUrl: string,
|
||||||
|
originalValue: boolean | undefined
|
||||||
|
) {
|
||||||
|
if (typeof originalValue === 'boolean') {
|
||||||
|
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveSubdomainMatchSetting(request, workerUrl, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Create Address Subdomain Match', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||||
|
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||||
|
|
||||||
|
if (WORKER_URL_ENV_OFF) {
|
||||||
|
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
|
||||||
|
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
|
||||||
|
if (WORKER_URL_ENV_OFF) {
|
||||||
|
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can clear override and return to env fallback', async ({ request }) => {
|
||||||
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||||
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
|
||||||
|
|
||||||
|
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||||
|
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
|
||||||
|
|
||||||
|
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
expect(await res.text()).toContain('Invalid domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
|
||||||
|
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||||
|
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
|
||||||
|
|
||||||
|
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
|
||||||
|
data: buildAccountSettingsPayload(
|
||||||
|
current,
|
||||||
|
{ enableSubdomainMatch: 'invalid-value' as any },
|
||||||
|
{
|
||||||
|
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
|
||||||
|
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||||
|
expect(after.blockList || []).toEqual(current.blockList || []);
|
||||||
|
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
|
||||||
|
current.addressCreationSubdomainMatchStatus?.storedEnabled
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||||
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||||
|
|
||||||
|
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||||
|
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
expect(await res.text()).toContain('Invalid domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||||
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||||
|
|
||||||
|
const adminName = `subdomain-admin-${Date.now()}`;
|
||||||
|
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: adminName, domain: SUBDOMAIN },
|
||||||
|
});
|
||||||
|
expect(adminRes.ok()).toBe(true);
|
||||||
|
const adminBody = await adminRes.json();
|
||||||
|
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
|
||||||
|
expect(adminBody.address_id).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const userName = `subdomain-user-${Date.now()}`;
|
||||||
|
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name: userName, domain: NESTED_SUBDOMAIN },
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userBody = await userRes.json();
|
||||||
|
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
|
||||||
|
expect(userBody.address_id).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
|
||||||
|
});
|
||||||
|
expect(mixedCaseRes.ok()).toBe(true);
|
||||||
|
const mixedCaseBody = await mixedCaseRes.json();
|
||||||
|
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
|
||||||
|
|
||||||
|
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(invalidRes.ok()).toBe(false);
|
||||||
|
expect(await invalidRes.text()).toContain('Invalid domain');
|
||||||
|
|
||||||
|
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(invalidEmptyPrefixRes.ok()).toBe(false);
|
||||||
|
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
|
||||||
|
|
||||||
|
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(invalidEmptyLabelRes.ok()).toBe(false);
|
||||||
|
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
|
||||||
|
|
||||||
|
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(invalidOverlongRes.ok()).toBe(false);
|
||||||
|
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||||
|
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||||
|
|
||||||
|
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||||
|
|
||||||
|
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||||
|
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(false);
|
||||||
|
expect(await res.text()).toContain('Invalid domain');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, h } from 'vue';
|
import { computed, onMounted, ref, h } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||||
|
|
||||||
@@ -46,6 +46,14 @@ const { t } = useI18n({
|
|||||||
regex_invalid: 'Invalid regex pattern',
|
regex_invalid: 'Invalid regex pattern',
|
||||||
forward_address_required: 'Forward address is required',
|
forward_address_required: 'Forward address is required',
|
||||||
rule_index: 'Rule',
|
rule_index: 'Rule',
|
||||||
|
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
|
||||||
|
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
|
||||||
|
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
|
||||||
|
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
|
||||||
|
create_address_subdomain_match_force_enable: 'Force Enable',
|
||||||
|
create_address_subdomain_match_force_disable: 'Force Disable',
|
||||||
|
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
|
||||||
|
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||||
@@ -82,6 +90,14 @@ const { t } = useI18n({
|
|||||||
regex_invalid: '无效的正则表达式',
|
regex_invalid: '无效的正则表达式',
|
||||||
forward_address_required: '转发地址不能为空',
|
forward_address_required: '转发地址不能为空',
|
||||||
rule_index: '规则',
|
rule_index: '规则',
|
||||||
|
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
|
||||||
|
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后,foo.example.com 可以匹配已配置的基础域名 example.com。',
|
||||||
|
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
|
||||||
|
create_address_subdomain_match_follow_env: '跟随环境变量',
|
||||||
|
create_address_subdomain_match_force_enable: '强制开启',
|
||||||
|
create_address_subdomain_match_force_disable: '强制关闭',
|
||||||
|
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
|
||||||
|
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存,但在 env 打开或移除前不会生效。',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -95,6 +111,38 @@ const emailRuleSettings = ref({
|
|||||||
blockReceiveUnknowAddressEmail: false,
|
blockReceiveUnknowAddressEmail: false,
|
||||||
emailForwardingList: []
|
emailForwardingList: []
|
||||||
})
|
})
|
||||||
|
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
|
||||||
|
FOLLOW_ENV: 'follow_env',
|
||||||
|
FORCE_ENABLE: 'force_enable',
|
||||||
|
FORCE_DISABLE: 'force_disable'
|
||||||
|
}
|
||||||
|
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
|
||||||
|
const addressCreationSubdomainMatchStatus = ref({
|
||||||
|
envConfigured: false,
|
||||||
|
envEnabled: false,
|
||||||
|
storedEnabled: undefined,
|
||||||
|
effectiveEnabled: false
|
||||||
|
})
|
||||||
|
const subdomainMatchEnvLocked = computed(() => {
|
||||||
|
return addressCreationSubdomainMatchStatus.value.envConfigured
|
||||||
|
&& !addressCreationSubdomainMatchStatus.value.envEnabled
|
||||||
|
})
|
||||||
|
const subdomainMatchModeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
|
||||||
|
label: t('create_address_subdomain_match_follow_env')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
|
||||||
|
label: t('create_address_subdomain_match_force_enable')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
|
||||||
|
label: t('create_address_subdomain_match_force_disable')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const showEmailForwardingModal = ref(false)
|
const showEmailForwardingModal = ref(false)
|
||||||
const emailForwardingList = ref([])
|
const emailForwardingList = ref([])
|
||||||
@@ -246,8 +294,27 @@ const saveEmailForwardingConfig = () => {
|
|||||||
showEmailForwardingModal.value = false
|
showEmailForwardingModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
|
||||||
|
if (storedEnabled === true) {
|
||||||
|
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
|
||||||
|
}
|
||||||
|
if (storedEnabled === false) {
|
||||||
|
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
|
||||||
|
}
|
||||||
|
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const getSubdomainMatchPayloadValue = (mode) => {
|
||||||
|
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.fetch(`/admin/account_settings`)
|
const res = await api.fetch(`/admin/account_settings`)
|
||||||
addressBlockList.value = res.blockList || []
|
addressBlockList.value = res.blockList || []
|
||||||
@@ -259,33 +326,63 @@ const fetchData = async () => {
|
|||||||
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
||||||
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
||||||
}
|
}
|
||||||
|
addressCreationSubdomainMatchStatus.value = {
|
||||||
|
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
|
||||||
|
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
|
||||||
|
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
|
||||||
|
? res.addressCreationSubdomainMatchStatus.storedEnabled
|
||||||
|
: undefined,
|
||||||
|
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
|
||||||
|
}
|
||||||
|
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
|
||||||
|
addressCreationSubdomainMatchStatus.value.storedEnabled
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || "error");
|
if (!suppressErrorMessage) {
|
||||||
|
message.error(error.message || "error");
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
blockList: addressBlockList.value || [],
|
||||||
|
sendBlockList: sendAddressBlockList.value || [],
|
||||||
|
verifiedAddressList: verifiedAddressList.value || [],
|
||||||
|
fromBlockList: fromBlockList.value || [],
|
||||||
|
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||||
|
emailRuleSettings: emailRuleSettings.value,
|
||||||
|
addressCreationSettings: {
|
||||||
|
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
await api.fetch(`/admin/account_settings`, {
|
await api.fetch(`/admin/account_settings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload)
|
||||||
blockList: addressBlockList.value || [],
|
|
||||||
sendBlockList: sendAddressBlockList.value || [],
|
|
||||||
verifiedAddressList: verifiedAddressList.value || [],
|
|
||||||
fromBlockList: fromBlockList.value || [],
|
|
||||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
|
||||||
emailRuleSettings: emailRuleSettings.value,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
message.success(t('successTip'))
|
message.success(t('successTip'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || "error");
|
message.error(error.message || "error");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchData({ suppressErrorMessage: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to refresh account settings after save', error)
|
||||||
|
message.warning(error.message || "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchData();
|
try {
|
||||||
|
await fetchData();
|
||||||
|
} catch {
|
||||||
|
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -352,6 +449,29 @@ onMounted(async () => {
|
|||||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
|
<n-form-item-row :label="t('create_address_subdomain_match')">
|
||||||
|
<n-flex vertical style="width: 100%;">
|
||||||
|
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
|
||||||
|
<n-space vertical size="small">
|
||||||
|
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
|
||||||
|
{{ item.label }}
|
||||||
|
</n-radio>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
<n-text depth="3">
|
||||||
|
{{ t('create_address_subdomain_match_tip') }}
|
||||||
|
</n-text>
|
||||||
|
<n-text depth="3">
|
||||||
|
{{ t('create_address_subdomain_match_note') }}
|
||||||
|
</n-text>
|
||||||
|
<n-text depth="3">
|
||||||
|
{{ t('create_address_subdomain_match_follow_env_note') }}
|
||||||
|
</n-text>
|
||||||
|
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
|
||||||
|
{{ t('create_address_subdomain_match_env_locked') }}
|
||||||
|
</n-alert>
|
||||||
|
</n-flex>
|
||||||
|
</n-form-item-row>
|
||||||
<n-form-item-row :label="t('email_forwarding_config')">
|
<n-form-item-row :label="t('email_forwarding_config')">
|
||||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
|
|||||||
@@ -39,6 +39,32 @@ res = requests.post(
|
|||||||
print(res.json())
|
print(res.json())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Create a Subdomain Mailbox Address
|
||||||
|
|
||||||
|
If your base domain is already configured in `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`, and
|
||||||
|
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is enabled (it can also be toggled in the admin panel),
|
||||||
|
the create-address APIs can accept subdomains directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
res = requests.post(
|
||||||
|
"https://xxxx.xxxx/admin/new_address",
|
||||||
|
json={
|
||||||
|
"enablePrefix": True,
|
||||||
|
"name": "project001",
|
||||||
|
"domain": "team.example.com",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
'x-admin-auth': "<your_website_admin_password>",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- If `example.com` is an allowed base domain, `team.example.com` and `dev.team.example.com` can match successfully
|
||||||
|
- Lookalike domains such as `badexample.com` will **not** be treated as `example.com`
|
||||||
|
- This is different from `RANDOM_SUBDOMAIN_DOMAINS`: here the caller **explicitly specifies** the subdomain, instead of the system generating a random one
|
||||||
|
- In the admin panel, this can be set to **Follow Environment Variable / Force Enable / Force Disable**. Choosing **Follow Environment Variable** clears the admin override and returns to env fallback behavior.
|
||||||
|
|
||||||
## Batch Create Random Username Email Addresses API Example
|
## Batch Create Random Username Email Addresses API Example
|
||||||
|
|
||||||
### Batch Create Email Addresses via Admin API
|
### Batch Create Email Addresses via Admin API
|
||||||
|
|||||||
@@ -35,3 +35,24 @@ RANDOM_SUBDOMAIN_LENGTH = 8
|
|||||||
>
|
>
|
||||||
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
|
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
|
||||||
> so make sure the base-domain/subdomain routing is already available first.
|
> so make sure the base-domain/subdomain routing is already available first.
|
||||||
|
|
||||||
|
## Let APIs Specify Subdomains Directly
|
||||||
|
|
||||||
|
If you do not want the system to generate a random subdomain, and instead want the caller to
|
||||||
|
explicitly create addresses like `team.abc.com`, enable:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||||
|
```
|
||||||
|
|
||||||
|
When this is enabled, as long as `abc.com` is in the allowed base-domain list, the following
|
||||||
|
addresses can be created through `/api/new_address` or `/admin/new_address`:
|
||||||
|
|
||||||
|
- `name@team.abc.com`
|
||||||
|
- `name@dev.team.abc.com`
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This only relaxes the domain validation used by the create-address APIs. It does not change the
|
||||||
|
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
|
||||||
|
>
|
||||||
|
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
|
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
|
||||||
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
|
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
|
||||||
|
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
|
||||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
|
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
|
||||||
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
|
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
|
||||||
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||||
@@ -45,6 +46,18 @@
|
|||||||
>
|
>
|
||||||
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
|
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
|
||||||
> domain.
|
> domain.
|
||||||
|
>
|
||||||
|
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
|
||||||
|
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
|
||||||
|
> generation appends one automatically during creation.
|
||||||
|
>
|
||||||
|
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
|
||||||
|
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
|
||||||
|
> value is only used as a fallback when no admin setting has been saved.
|
||||||
|
>
|
||||||
|
> The admin panel exposes three explicit states: **Follow Environment Variable**, **Force Enable**,
|
||||||
|
> and **Force Disable**. Saving **Follow Environment Variable** clears the admin override and returns
|
||||||
|
> the feature to the "unset" fallback behavior.
|
||||||
|
|
||||||
## Email Reception Related Variables
|
## Email Reception Related Variables
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ res = requests.post(
|
|||||||
print(res.json())
|
print(res.json())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 创建子域名邮箱地址
|
||||||
|
|
||||||
|
如果你已经把基础域名配置进 `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`,并且开启了
|
||||||
|
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH`(管理后台也可单独开关),那么创建地址 API 可以直接接收子域名:
|
||||||
|
|
||||||
|
```python
|
||||||
|
res = requests.post(
|
||||||
|
"https://xxxx.xxxx/admin/new_address",
|
||||||
|
json={
|
||||||
|
"enablePrefix": True,
|
||||||
|
"name": "project001",
|
||||||
|
"domain": "team.example.com",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
'x-admin-auth': "<你的网站admin密码>",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果允许域名里有 `example.com`,则 `team.example.com`、`dev.team.example.com` 都可以匹配成功
|
||||||
|
- `badexample.com` 这种**不是点分后缀**的域名不会被误判为 `example.com`
|
||||||
|
- 这与 `RANDOM_SUBDOMAIN_DOMAINS` 不同:这里是**由调用方显式指定子域名**,不是系统自动生成随机子域名
|
||||||
|
- 管理后台可以把该能力设置为“跟随环境变量 / 强制开启 / 强制关闭”;其中“跟随环境变量”会清空后台覆盖,恢复到未设置后按 env 回退
|
||||||
|
|
||||||
## 批量创建随机用户名邮箱地址 API 示例
|
## 批量创建随机用户名邮箱地址 API 示例
|
||||||
|
|
||||||
### 通过 admin API 批量新建邮箱地址
|
### 通过 admin API 批量新建邮箱地址
|
||||||
|
|||||||
@@ -33,3 +33,25 @@ RANDOM_SUBDOMAIN_LENGTH = 8
|
|||||||
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
|
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
|
||||||
>
|
>
|
||||||
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
|
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
|
||||||
|
|
||||||
|
## 允许 API 直接指定子域名
|
||||||
|
|
||||||
|
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
|
||||||
|
可以开启:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||||
|
```
|
||||||
|
|
||||||
|
开启后,只要允许域名里包含基础域名 `abc.com`,那么:
|
||||||
|
|
||||||
|
- `name@team.abc.com`
|
||||||
|
- `name@dev.team.abc.com`
|
||||||
|
|
||||||
|
都可以通过 `/api/new_address` 或 `/admin/new_address` 创建。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 这个能力只放宽“创建地址 API 的域名校验”,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
|
||||||
|
> 子域名邮箱路由。
|
||||||
|
>
|
||||||
|
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
|
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
|
||||||
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
||||||
|
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | 文本/JSON | 是否允许创建邮箱 API 使用“基础域名后缀匹配”。开启后,如果允许域名里有 `example.com`,则 `/api/new_address` 与 `/admin/new_address` 可以接受 `foo.example.com`、`a.b.example.com` 这类子域名 | `true` |
|
||||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
||||||
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
|
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
|
||||||
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||||
@@ -44,6 +45,15 @@
|
|||||||
> 侧的子域名路由。
|
> 侧的子域名路由。
|
||||||
>
|
>
|
||||||
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
|
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
|
||||||
|
>
|
||||||
|
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
|
||||||
|
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀。
|
||||||
|
>
|
||||||
|
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
|
||||||
|
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
|
||||||
|
>
|
||||||
|
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
|
||||||
|
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
|
||||||
|
|
||||||
## 接受邮件相关变量
|
## 接受邮件相关变量
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Jwt } from 'hono/utils/jwt'
|
|||||||
|
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
|
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 { CONSTANTS } from '../constants'
|
||||||
import cleanup_api from './cleanup_api'
|
import cleanup_api from './cleanup_api'
|
||||||
import admin_user_api from './admin_user_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>()
|
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) => {
|
api.get('/admin/address', async (c) => {
|
||||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||||
const allowedSortColumns: Record<string, string> = {
|
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 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 emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_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({
|
return c.json({
|
||||||
blockList: blockList || [],
|
blockList: blockList || [],
|
||||||
sendBlockList: sendBlockList || [],
|
sendBlockList: sendBlockList || [],
|
||||||
verifiedAddressList: verifiedAddressList || [],
|
verifiedAddressList: verifiedAddressList || [],
|
||||||
fromBlockList: fromBlockList || [],
|
fromBlockList: fromBlockList || [],
|
||||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||||
emailRuleSettings: emailRuleSettings || {}
|
emailRuleSettings: emailRuleSettings || {},
|
||||||
|
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||||
|
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||||
|
: {},
|
||||||
|
addressCreationSubdomainMatchStatus,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -313,14 +359,22 @@ api.post('/admin/account_settings', async (c) => {
|
|||||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||||
const {
|
const {
|
||||||
blockList, sendBlockList, noLimitSendAddressList,
|
blockList, sendBlockList, noLimitSendAddressList,
|
||||||
verifiedAddressList, fromBlockList, emailRuleSettings
|
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
|
||||||
} = await c.req.json();
|
} = await c.req.json();
|
||||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||||
return c.text(msgs.InvalidInputMsg, 400)
|
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) {
|
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||||
return c.text(msgs.EnableSendMailMsg, 400)
|
return c.text(msgs.EnableSendMailMsg, 400)
|
||||||
}
|
}
|
||||||
|
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||||
|
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||||
|
return c.text(msgs.EnableKVMsg, 400)
|
||||||
|
}
|
||||||
await saveSetting(
|
await saveSetting(
|
||||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||||
JSON.stringify(blockList)
|
JSON.stringify(blockList)
|
||||||
@@ -333,9 +387,6 @@ api.post('/admin/account_settings', async (c) => {
|
|||||||
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
||||||
JSON.stringify(verifiedAddressList)
|
JSON.stringify(verifiedAddressList)
|
||||||
)
|
)
|
||||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
|
||||||
return c.text(msgs.EnableKVMsg, 400)
|
|
||||||
}
|
|
||||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
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,
|
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
|
||||||
JSON.stringify(emailRuleSettings || {})
|
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({
|
return c.json({
|
||||||
success: true
|
success: true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default {
|
|||||||
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
|
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
|
||||||
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
|
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
|
||||||
"DOMAINS": utils.getDomains(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_DOMAINS": utils.getRandomSubdomainDomains(c),
|
||||||
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
|
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
|
||||||
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
|
"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 { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
|
||||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||||
import { CONSTANTS } from './constants';
|
import { CONSTANTS } from './constants';
|
||||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||||
const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5;
|
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
|
* Check if send mail is enabled for a specific domain
|
||||||
@@ -85,7 +99,98 @@ const allowRandomSubdomainForDomain = (
|
|||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
domain: string
|
domain: string
|
||||||
): boolean => {
|
): 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) => {
|
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
|
||||||
@@ -259,13 +364,19 @@ export const newAddress = async (
|
|||||||
if (!domain && allowDomains.length > 0) {
|
if (!domain && allowDomains.length > 0) {
|
||||||
const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
|
const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
|
||||||
if (createAddressDefaultDomainFirst) {
|
if (createAddressDefaultDomainFirst) {
|
||||||
domain = allowDomains[0];
|
domain = normalizeDomainValue(allowDomains[0]);
|
||||||
} else {
|
} 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
|
// check domain is valid
|
||||||
if (!domain || !allowDomains.includes(domain)) {
|
if (!domain || !matchedAllowDomain) {
|
||||||
throw new Error(msgs.InvalidDomainMsg)
|
throw new Error(msgs.InvalidDomainMsg)
|
||||||
}
|
}
|
||||||
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
|
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const CONSTANTS = {
|
|||||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||||
USER_SETTINGS_KEY: 'user_settings',
|
USER_SETTINGS_KEY: 'user_settings',
|
||||||
|
ADDRESS_CREATION_SETTINGS_KEY: 'address_creation_settings',
|
||||||
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
|
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
|
||||||
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
||||||
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_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 {
|
export class UserInfo {
|
||||||
|
|
||||||
geoData: GeoData;
|
geoData: GeoData;
|
||||||
|
|||||||
Vendored
+1
@@ -25,6 +25,7 @@ type Bindings = {
|
|||||||
MAX_ADDRESS_LEN: string | number | undefined
|
MAX_ADDRESS_LEN: string | number | undefined
|
||||||
DEFAULT_DOMAINS: string | string[] | undefined
|
DEFAULT_DOMAINS: string | string[] | undefined
|
||||||
DOMAINS: string | string[] | undefined
|
DOMAINS: string | string[] | undefined
|
||||||
|
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH: string | boolean | undefined
|
||||||
RANDOM_SUBDOMAIN_DOMAINS: string | string[] | undefined
|
RANDOM_SUBDOMAIN_DOMAINS: string | string[] | undefined
|
||||||
RANDOM_SUBDOMAIN_LENGTH: string | number | undefined
|
RANDOM_SUBDOMAIN_LENGTH: string | number | undefined
|
||||||
DISABLE_CUSTOM_ADDRESS_NAME: string | boolean | undefined
|
DISABLE_CUSTOM_ADDRESS_NAME: string | boolean | undefined
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ PREFIX = "tmp"
|
|||||||
# CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = false
|
# CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = false
|
||||||
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
|
DEFAULT_DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # domain name for no role users
|
||||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # all domain names
|
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
|
# Allow optional random subdomain generation for the listed base domains
|
||||||
# e.g. name@abc.com => name@r4nd0m.abc.com
|
# e.g. name@abc.com => name@r4nd0m.abc.com
|
||||||
# RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
# RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||||
|
|||||||
Reference in New Issue
Block a user