diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c52bb1..c1bf0e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918) - 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: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767) - feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894) @@ -24,10 +25,12 @@ ### Testing +- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级 - test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景 ### Docs +- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别 - docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910) - docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769) - docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 054fa3a1..5e774ef5 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -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 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: |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) @@ -24,10 +25,12 @@ ### 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 ### 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: |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 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 2a090110..c7950c14 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -20,6 +20,40 @@ services: start_period: 10s 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: build: context: .. @@ -73,6 +107,8 @@ services: dockerfile: e2e/Dockerfile.e2e environment: 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 MAILPIT_API: http://mailpit:8025/api SMTP_PROXY_HOST: smtp-proxy @@ -85,6 +121,10 @@ services: depends_on: worker: condition: service_healthy + worker-subdomain: + condition: service_healthy + worker-env-off: + condition: service_healthy frontend: condition: service_started smtp-proxy: diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 2720e0ec..839b100f 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -2,6 +2,8 @@ import { APIRequestContext } from '@playwright/test'; import WebSocket from 'ws'; 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 MAILPIT_API = process.env.MAILPIT_API!; export const TEST_DOMAIN = 'test.example.com'; diff --git a/e2e/fixtures/wrangler.toml.e2e.env-off b/e2e/fixtures/wrangler.toml.e2e.env-off new file mode 100644 index 00000000..17b0acb6 --- /dev/null +++ b/e2e/fixtures/wrangler.toml.e2e.env-off @@ -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" diff --git a/e2e/scripts/docker-entrypoint.sh b/e2e/scripts/docker-entrypoint.sh index 334a4ee6..40211cae 100755 --- a/e2e/scripts/docker-entrypoint.sh +++ b/e2e/scripts/docker-entrypoint.sh @@ -14,6 +14,36 @@ for i in $(seq 1 60); do sleep 1 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 ..." for i in $(seq 1 60); do 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 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" exec npx playwright test "$@" diff --git a/e2e/tests/api/subdomain-create.spec.ts b/e2e/tests/api/subdomain-create.spec.ts new file mode 100644 index 00000000..15b35a47 --- /dev/null +++ b/e2e/tests/api/subdomain-create.spec.ts @@ -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 = {} +) { + 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'); + }); +}); diff --git a/frontend/src/views/admin/AccountSettings.vue b/frontend/src/views/admin/AccountSettings.vue index d18b73dd..54f29072 100644 --- a/frontend/src/views/admin/AccountSettings.vue +++ b/frontend/src/views/admin/AccountSettings.vue @@ -1,5 +1,5 @@ @@ -352,6 +449,29 @@ onMounted(async () => { + + + + + + {{ item.label }} + + + + + {{ t('create_address_subdomain_match_tip') }} + + + {{ t('create_address_subdomain_match_note') }} + + + {{ t('create_address_subdomain_match_follow_env_note') }} + + + {{ t('create_address_subdomain_match_env_locked') }} + + + {{ t('config') }} diff --git a/vitepress-docs/docs/en/guide/feature/new-address-api.md b/vitepress-docs/docs/en/guide/feature/new-address-api.md index cfb89333..161dc953 100644 --- a/vitepress-docs/docs/en/guide/feature/new-address-api.md +++ b/vitepress-docs/docs/en/guide/feature/new-address-api.md @@ -39,6 +39,32 @@ res = requests.post( 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': "", + "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 Email Addresses via Admin API diff --git a/vitepress-docs/docs/en/guide/feature/subdomain.md b/vitepress-docs/docs/en/guide/feature/subdomain.md index 24e3b7cc..0056fa51 100644 --- a/vitepress-docs/docs/en/guide/feature/subdomain.md +++ b/vitepress-docs/docs/en/guide/feature/subdomain.md @@ -35,3 +35,24 @@ RANDOM_SUBDOMAIN_LENGTH = 8 > > 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. + +## 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. diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index 8af13503..68a7434f 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -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]` | | `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` | +| `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@.abc.com` | `["abc.com"]` | | `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"]` | @@ -45,6 +46,18 @@ > > Subdomain addresses are usually best used for receiving only; for sending, prefer the main > 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 diff --git a/vitepress-docs/docs/zh/guide/feature/new-address-api.md b/vitepress-docs/docs/zh/guide/feature/new-address-api.md index 1fd03369..2baa3704 100644 --- a/vitepress-docs/docs/zh/guide/feature/new-address-api.md +++ b/vitepress-docs/docs/zh/guide/feature/new-address-api.md @@ -39,6 +39,31 @@ res = requests.post( 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 示例 ### 通过 admin API 批量新建邮箱地址 diff --git a/vitepress-docs/docs/zh/guide/feature/subdomain.md b/vitepress-docs/docs/zh/guide/feature/subdomain.md index 08958f28..44ab7b87 100644 --- a/vitepress-docs/docs/zh/guide/feature/subdomain.md +++ b/vitepress-docs/docs/zh/guide/feature/subdomain.md @@ -33,3 +33,25 @@ RANDOM_SUBDOMAIN_LENGTH = 8 > 这个功能只是在“创建地址”时自动补一个随机二级域名。 > > 它不会自动帮你创建 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 默认值。 diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index 11ac0be9..38a9721e 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -32,6 +32,7 @@ | `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` | | `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` | | `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_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` | | `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 值。 +> +> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择 +> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。 ## 接受邮件相关变量 diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 9ee22f6d..a84ea9f3 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -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() +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).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 = { @@ -294,13 +334,19 @@ api.get('/admin/account_settings', async (c) => { const fromBlockList = c.env.KV ? await c.env.KV.get(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : []; const emailRuleSettings = await getJsonSetting(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, sendBlockList: Array }} */ 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 }) diff --git a/worker/src/admin_api/worker_config.ts b/worker/src/admin_api/worker_config.ts index be8123c1..ff632496 100644 --- a/worker/src/admin_api/worker_config.ts +++ b/worker/src/admin_api/worker_config.ts @@ -24,6 +24,7 @@ export default { "SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue(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), diff --git a/worker/src/common.ts b/worker/src/common.ts index a95740ef..dd82a1ef 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -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, 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): 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 +): Promise => { + const value = await getJsonSetting( + c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY + ); + return new AddressCreationSettings(value); +} + +export const getAddressCreationSubdomainMatchStatus = async ( + c: Context, + 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, 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)) { diff --git a/worker/src/constants.ts b/worker/src/constants.ts index db200063..5a6de7f3 100644 --- a/worker/src/constants.ts +++ b/worker/src/constants.ts @@ -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', diff --git a/worker/src/models/index.ts b/worker/src/models/index.ts index 1c15ff26..04de6e7a 100644 --- a/worker/src/models/index.ts +++ b/worker/src/models/index.ts @@ -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; diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts index e23256d5..7518cec3 100644 --- a/worker/src/types.d.ts +++ b/worker/src/types.d.ts @@ -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 diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template index 25858c20..259b2bbf 100644 --- a/worker/wrangler.toml.template +++ b/worker/wrangler.toml.template @@ -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"]