feat: allow custom subdomains in create UI (#1109)

* feat: allow custom subdomains in create UI

* test: stabilize custom subdomain browser flow

* test: fix normalized address expectation

* fix: gate custom subdomain input by setting

* fix: scope custom subdomains to random domains

* test: recreate random subdomain manually

* refactor: minimize custom subdomain changes

* refactor: reduce custom subdomain changes

* test: fix custom subdomain locator

* refactor: clarify manual subdomain validation

* test: target visible custom subdomain input

* refactor: simplify manual subdomain validation

* refactor: use subdomain mode selector

* style: stack subdomain modes vertically

* test: click visible subdomain mode labels
This commit is contained in:
Dream Hunter
2026-08-19 12:10:09 +08:00
committed by GitHub
parent 624fc9bb96
commit a3c62de42f
17 changed files with 225 additions and 66 deletions
+2
View File
@@ -10,6 +10,8 @@
### Features
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108
### Bug Fixes
### Improvements
+2
View File
@@ -10,6 +10,8 @@
### Features
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
### Bug Fixes
### Improvements
+3 -1
View File
@@ -7,7 +7,9 @@ keep_vars = true
[vars]
PREFIX = "TMP"
DEFAULT_DOMAINS = []
DOMAINS = ["TEST.EXAMPLE.COM"]
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
USER_ROLES = [
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
{ domains = [], role = "empty-role", prefix = "EMPTY" },
+44 -12
View File
@@ -1,13 +1,15 @@
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 MANUAL_BASE_DOMAIN = 'manual.example.com';
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
let originalCreateAddressStoredEnabled: boolean | undefined;
let originalEnvOffStoredEnabled: boolean | undefined;
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
);
});
test('persisted false still keeps exact match only', async ({ request }) => {
test('random subdomain scope allows a manually entered subdomain', 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 },
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
expect(res.ok()).toBe(true);
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
});
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
});
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
const name = `subreuse${Date.now()}`;
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
});
expect(firstCreate.ok()).toBe(true);
const firstAddress = await firstCreate.json();
const generatedDomain = firstAddress.address.split('@')[1];
expect(generatedDomain).not.toBe(TEST_DOMAIN);
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
});
expect(firstDelete.ok()).toBe(true);
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: generatedDomain },
});
expect(secondCreate.ok()).toBe(true);
const secondAddress = await secondCreate.json();
expect(secondAddress.address).toBe(firstAddress.address);
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
});
expect(secondDelete.ok()).toBe(true);
});
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 },
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
let jwt: string | undefined;
try {
await page.goto(`${FRONTEND_URL}/en/`);
await page.getByRole('button', { name: 'Create New Email' }).click();
const name = `subui${Date.now()}`;
const createForm = page.locator('.n-tab-pane:visible form');
await createForm.locator('.n-input-group .n-input input').fill(name);
const domainSelect = createForm.locator('.n-input-group .n-select');
await expect(domainSelect.locator('input')).toHaveCount(0);
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
await expect(normalSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(normalSubdomain).not.toBeChecked();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await expect(randomSubdomain).not.toBeChecked();
await expect(customSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
await createForm.getByRole('button', { name: 'Create New Email' }).click();
const domain = `team.${TEST_DOMAIN}`;
const address = `tmp${name}@${domain}`;
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
expect(jwt).toBeTruthy();
} finally {
if (jwt) await deleteAddress(request, jwt);
}
});
+5 -1
View File
@@ -526,7 +526,7 @@ export const deMessages = {
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.common.Appearance.top": "oben",
@@ -582,6 +582,10 @@ export const deMessages = {
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.common.Login.normalSubdomain": "Normale Domain",
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
"views.Admin.workerconfig": "Worker-Konfiguration",
+5 -1
View File
@@ -526,7 +526,7 @@ export const esMessages = {
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.common.Appearance.top": "arriba",
@@ -582,6 +582,10 @@ export const esMessages = {
"views.admin.AiExtractSettings.disabledTip": "Si está desactivado, la extracción IA procesará todas las direcciones",
"views.admin.AiExtractSettings.enableAllowListTip": "Si está activado, la extracción IA solo procesará correos enviados a direcciones permitidas",
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
"views.common.Login.normalSubdomain": "Dominio normal",
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
"views.Admin.workerconfig": "Configuración del Worker",
+5 -1
View File
@@ -526,7 +526,7 @@ export const jaMessages = {
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.common.Appearance.top": "上",
@@ -582,6 +582,10 @@ export const jaMessages = {
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.common.Login.normalSubdomain": "通常ドメイン",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
"views.Admin.workerconfig": "Worker設定",
+5 -1
View File
@@ -526,7 +526,7 @@ export const ptBRMessages = {
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.common.Appearance.top": "topo",
@@ -582,6 +582,10 @@ export const ptBRMessages = {
"views.admin.AiExtractSettings.disabledTip": "Quando desativado, a extração por IA processará todos os endereços",
"views.admin.AiExtractSettings.enableAllowListTip": "Quando ativado, a extração por IA só processará e-mails enviados aos endereços permitidos",
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.common.Login.normalSubdomain": "Domínio normal",
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
"views.Admin.workerconfig": "Configuração do Worker",
+18 -2
View File
@@ -2000,6 +2000,10 @@ export const MESSAGE_REGISTRY = {
"en": "Create New Email",
"zh": "创建新邮箱"
},
"enableCustomSubdomain": {
"en": "Use Custom Subdomain",
"zh": "使用自定义子域名"
},
"enablePrefix": {
"en": "If enable Prefix",
"zh": "是否启用前缀"
@@ -2016,6 +2020,10 @@ export const MESSAGE_REGISTRY = {
"en": "Open to auto login email link",
"zh": "打开即可自动登录邮箱的链接"
},
"normalSubdomain": {
"en": "Normal Domain",
"zh": "普通域名"
},
"randomSubdomainTip": {
"en": "When enabled, the created address will use a random subdomain. Recommended for receiving only. Requires a wildcard MX DNS record on the base domain — see the random subdomain docs.",
"zh": "启用后,创建出来的地址会自动挂在随机子域名下,建议仅用于收件。需要在基础域名 DNS 中配置通配 MX 记录,详见随机子域名文档。"
@@ -2225,8 +2233,8 @@ export const MESSAGE_REGISTRY = {
"zh": "强制开启"
},
"create_address_subdomain_match_note": {
"en": "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.",
"zh": "这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名随机子域名功能只是在创建时自动补一个随机子域名。"
"en": "RANDOM_SUBDOMAIN_DOMAINS already allows random or manual subdomains for listed base domains. This switch additionally allows API callers to specify subdomains under other allowed base domains.",
"zh": "RANDOM_SUBDOMAIN_DOMAINS 已允许在所列基础域名随机生成或手动输入子域名;此开关额外允许 API 在其他已授权基础域名下指定子域名。"
},
"create_address_subdomain_match_tip": {
"en": "Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.",
@@ -2506,6 +2514,10 @@ export const MESSAGE_REGISTRY = {
"en": "Use Random Subdomain",
"zh": "启用随机子域名"
},
"enableCustomSubdomain": {
"en": "Use Custom Subdomain",
"zh": "使用自定义子域名"
},
"generateName": {
"en": "Generate Fake Name",
"zh": "生成随机名字"
@@ -2514,6 +2526,10 @@ export const MESSAGE_REGISTRY = {
"en": "Create New Email",
"zh": "创建新邮箱"
},
"normalSubdomain": {
"en": "Normal Domain",
"zh": "普通域名"
},
"getNewEmailTip1": {
"en": "Please input the email you want to use. only allow: ",
"zh": "请输入你想要使用的邮箱地址, 只允许: "
+26 -11
View File
@@ -14,7 +14,8 @@ const message = useMessage()
const { t } = useScopedI18n('views.admin.CreateAccount')
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
@@ -31,7 +32,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false
subdomainMode.value = "normal"
}
})
@@ -41,13 +42,16 @@ const newEmail = async () => {
return
}
try {
const domain = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
enableRandomSubdomain: subdomainMode.value === "random",
name: emailName.value,
domain: emailDomain.value,
domain,
})
})
result.value = res["jwt"];
@@ -88,14 +92,25 @@ onMounted(async () => {
</n-input-group>
</n-form-item-row>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
<n-button @click="newEmail" type="primary" block :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
{{ t('creatNewEmail') }}
</n-button>
</n-card>
+26 -11
View File
@@ -48,7 +48,8 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const loginCfToken = ref("")
const loginTurnstileRef = ref(null)
const loginMethod = ref('credential') // 'credential' or 'password'
@@ -167,11 +168,14 @@ const newEmail = async () => {
try {
// If custom names are disabled, send empty name to trigger backend auto-generation
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
const domainToSend = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value;
const res = await props.newAddressPath(
nameToSend,
emailDomain.value,
domainToSend,
cfToken.value,
enableRandomSubdomain.value
subdomainMode.value === "random"
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
@@ -206,7 +210,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false;
subdomainMode.value = "normal";
}
});
@@ -321,15 +325,26 @@ onMounted(async () => {
:options="domainsOptions" />
</n-input-group>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
@@ -27,7 +27,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow random or manually entered subdomains
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
The create-address APIs only generate a random subdomain when the request explicitly passes
@@ -47,6 +47,10 @@ the request body:
If you want to create an address under a specific subdomain such as `team.abc.com`, do not pass
`enableRandomSubdomain: true`; use the direct-subdomain flow below instead.
For base domains in `RANDOM_SUBDOMAIN_DOMAINS`, the web and admin pages offer **Normal Domain**,
**Use Random Subdomain**, and **Use Custom Subdomain** as single-choice modes. In custom mode,
enter only `team`; the frontend combines it as `team.abc.com`.
> [!NOTE]
> This feature only appends a random second-level subdomain when the mailbox is created.
>
@@ -77,10 +81,10 @@ If you want to create an address under a specific subdomain such as `team.abc.co
>
> Reference issue: [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## Let APIs Specify Subdomains Directly
## Let APIs Specify Other 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:
If a base domain is not in `RANDOM_SUBDOMAIN_DOMAINS`, but API callers still need to create
addresses like `team.abc.com` directly, enable:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
@@ -93,7 +97,7 @@ addresses can be created through `/api/new_address` or `/admin/new_address`:
- `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.
> This switch only relaxes create-address API domain validation. It does not change the frontend
> domain scope or 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.
+5 -6
View File
@@ -37,7 +37,7 @@
| `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@<random>.abc.com` | `["abc.com"]` |
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow random or manual subdomains; random mode can turn `name@abc.com` into `name@<random>.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"]` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
@@ -50,8 +50,8 @@
> [!NOTE]
> When `DEFAULT_DOMAINS` is unset or configured as an empty array, it falls back to `DOMAINS`.
>
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
> creation. It does not create Cloudflare-side subdomain routing for you.
> `RANDOM_SUBDOMAIN_DOMAINS` defines the base-domain scope shared by the frontend random and manual
> subdomain modes. It does not create Cloudflare-side subdomain routing for you.
>
> To actually receive mail on addresses like `name@<random>.abc.com`, **you must add a wildcard
> `*` MX record under the base domain in DNS** by copying the apex's existing MX records to
@@ -65,9 +65,8 @@
> 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` is independent from those frontend modes. It lets API
> callers **directly specify** subdomains such as `foo.example.com` under other allowed base domains.
>
> `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
@@ -26,7 +26,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`:允许用随机二级域名的基础域名列表
- `RANDOM_SUBDOMAIN_DOMAINS`:允许使用随机或手动子域名的基础域名列表
- `RANDOM_SUBDOMAIN_LENGTH`:随机串长度,范围 `1-63`,默认 `8`
创建地址 API 需要显式传入 `enableRandomSubdomain: true` 才会生成随机二级域名。前端勾选“启用随机二级域名”时会自动传这个字段;如果你自己调用 `/api/new_address``/admin/new_address`,也需要在请求体中传入:
@@ -41,6 +41,9 @@ RANDOM_SUBDOMAIN_LENGTH = 8
`domain` 必须传 `RANDOM_SUBDOMAIN_DOMAINS` 中配置的基础域名,例如 `abc.com`。如果要创建 `team.abc.com` 这种指定子域名地址,请不要传 `enableRandomSubdomain: true`,而是使用下方“直接指定子域名”的流程。
对于 `RANDOM_SUBDOMAIN_DOMAINS` 中的基础域名,网页端和管理后台会提供“普通域名”、
“启用随机子域名”和“使用自定义子域名”三种单选模式。自定义模式只需输入 `team`,前端会组合成 `team.abc.com`
> [!NOTE]
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
>
@@ -58,10 +61,10 @@ RANDOM_SUBDOMAIN_LENGTH = 8
>
> 参考 issue[#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## 允许 API 直接指定子域名
## 允许 API 直接指定其他子域名
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
可以开启:
如果基础域名不在 `RANDOM_SUBDOMAIN_DOMAINS` 中,但仍希望 API 调用方直接指定
`team.abc.com` 这种子域名,可以开启:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
@@ -75,7 +78,6 @@ ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
都可以通过 `/api/new_address``/admin/new_address` 创建。
> [!NOTE]
> 这个能力只放宽创建地址 API 的域名校验,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
> 子域名邮箱路由。
> 这个开关只放宽创建地址 API 的域名校验,不会改动前端的域名范围,也不会自动创建 Cloudflare 侧的子域名邮箱路由。
>
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。
+5 -5
View File
@@ -37,7 +37,7 @@
| `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_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"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
@@ -50,8 +50,8 @@
> [!NOTE]
> `DEFAULT_DOMAINS` 未配置或配置为空数组时,会回退使用 `DOMAINS`。
>
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
> 侧的子域名路由。
> `RANDOM_SUBDOMAIN_DOMAINS` 定义前端随机及手动子域名模式共同使用的基础域名范围,不会自动帮你
> 创建 Cloudflare 侧的子域名路由。
>
> 要让 `name@<随机>.abc.com` 这种随机子域名地址真的能收到邮件,**必须在基础域名的 DNS 中为
> `*` 子域添加通配 MX 记录**:把基础域名上现有的每一条 MX 记录都复制到 `*` 主机名上,
@@ -62,8 +62,8 @@
>
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与上述前端模式独立:它允许 API 调用方在其他允许的
> 基础域名下**直接指定** `foo.example.com` 这类子域名。
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
+6 -2
View File
@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { WorkerMailerOptions } from 'worker-mailer';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, normalizeDomains, trimLower } from './utils';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
@@ -410,8 +410,12 @@ export const newAddress = async (
domain = normalizeDomainValue(domain);
}
const { effectiveEnabled: enableSubdomainMatch } = await getAddressCreationSubdomainMatchStatus(c);
const allowManualSubdomain = domain
? allowDomains.some((baseDomain) =>
allowRandomSubdomainForDomain(c, baseDomain) && isDomainOrSubdomain(domain, baseDomain))
: false;
const matchedAllowDomain = domain
? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch)
? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch || allowManualSubdomain)
: null;
// check domain is valid
if (!domain || !matchedAllowDomain) {