From add0124cfd0c41dbae8d83cec6c467309ce0e962 Mon Sep 17 00:00:00 2001 From: Hging Date: Sat, 16 May 2026 03:35:39 -0700 Subject: [PATCH] fix: normalize domain casing Fix domain casing normalization for configured domains and inbound recipient domains. --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/fixtures/wrangler.toml.e2e | 12 +- .../wrangler.toml.e2e.send-mail-domain | 10 +- e2e/tests/api/admin-new-address.spec.ts | 112 +++++++++++ ...email-forward-domain-normalization.spec.ts | 175 ++++++++++++++++++ e2e/tests/api/health.spec.ts | 1 + e2e/tests/api/send-mail-limit.spec.ts | 21 ++- .../api/user-domain-normalization.spec.ts | 118 ++++++++++++ e2e/tests/smtp-proxy/imap-proxy.spec.ts | 6 +- vitepress-docs/docs/en/guide/worker-vars.md | 4 +- vitepress-docs/docs/zh/guide/worker-vars.md | 4 +- worker/src/admin_api/admin_user_api.ts | 6 +- worker/src/admin_api/e2e_test_api.ts | 11 +- worker/src/admin_api/send_mail.ts | 5 +- worker/src/commom_api.ts | 2 +- worker/src/common.ts | 24 +-- worker/src/email/auto_reply.ts | 12 +- worker/src/email/forward.ts | 15 +- worker/src/email/index.ts | 37 ++-- worker/src/mails_api/send_mail_api.ts | 10 +- worker/src/telegram_api/telegram.ts | 4 +- worker/src/user_api/oauth2.ts | 6 +- worker/src/user_api/user.ts | 10 +- worker/src/utils.ts | 116 +++++++++++- worker/src/worker.ts | 4 +- 26 files changed, 641 insertions(+), 86 deletions(-) create mode 100644 e2e/tests/api/email-forward-domain-normalization.spec.ts create mode 100644 e2e/tests/api/user-domain-normalization.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25993457..11e206e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码 - fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段 +- fix: |Address| 统一规范化配置域名、收件地址域名与前缀的空白和大小写,覆盖 `DOMAINS`、`DEFAULT_DOMAINS`、`USER_ROLES.domains`、随机子域名、转发规则、SMTP 与 `SEND_MAIL` 域名匹配,保留转发规则空域名 catch-all 行为,并明确空 `DEFAULT_DOMAINS` / 角色域名回退到 `DOMAINS` 的行为,避免大小写配置或入站收件域名导致创建、收件、转发或发信失败(issue #926) - fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029) - fix: |CI| 将 GitHub Actions 与 e2e Docker 镜像统一升级到 Node.js 24,适配 Wrangler 4.90.0 的运行时要求 - fix: |Frontend| 修复 iOS Safari 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 125f888d..c6d7519e 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -17,6 +17,7 @@ - fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext - fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields +- fix: |Address| Normalize whitespace and casing for configured domains, inbound recipient domains, and prefixes across `DOMAINS`, `DEFAULT_DOMAINS`, `USER_ROLES.domains`, random subdomains, forwarding rules, SMTP, and `SEND_MAIL` domain matching, preserve blank-domain catch-all forwarding rules, and clarify that empty `DEFAULT_DOMAINS` / role domains fall back to `DOMAINS`, to avoid create, receive, forward, or send failures caused by mixed-case configuration or inbound recipient domains (issue #926) - fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029) - fix: |CI| Upgrade GitHub Actions and e2e Docker images to Node.js 24 to satisfy Wrangler 4.90.0 runtime requirements - fix: |Frontend| Prevent iOS Safari from auto-zooming the page when focusing mobile form controls with small font sizes diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index b0805260..57514412 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -5,9 +5,13 @@ compatibility_flags = [ "nodejs_compat" ] keep_vars = true [vars] -PREFIX = "tmp" -DEFAULT_DOMAINS = ["test.example.com"] -DOMAINS = ["test.example.com"] +PREFIX = "TMP" +DEFAULT_DOMAINS = [] +DOMAINS = ["TEST.EXAMPLE.COM"] +USER_ROLES = [ + { domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" }, + { domains = [], role = "empty-role", prefix = "EMPTY" }, +] JWT_SECRET = "e2e-test-secret-key" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true @@ -20,7 +24,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true SMTP_CONFIG = """ -{"test.example.com":{"host":"mailpit","port":1025,"secure":false}} +{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} """ [[kv_namespaces]] diff --git a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain index 86072607..b227ad4f 100644 --- a/e2e/fixtures/wrangler.toml.e2e.send-mail-domain +++ b/e2e/fixtures/wrangler.toml.e2e.send-mail-domain @@ -9,10 +9,10 @@ send_email = [ ] [vars] -PREFIX = "tmp" -DEFAULT_DOMAINS = ["test.example.com"] -DOMAINS = ["test.example.com"] -SEND_MAIL_DOMAINS = ["test.example.com"] +PREFIX = "TMP" +DEFAULT_DOMAINS = ["TEST.EXAMPLE.COM"] +DOMAINS = ["TEST.EXAMPLE.COM"] +SEND_MAIL_DOMAINS = ["TEST.EXAMPLE.COM"] JWT_SECRET = "e2e-test-secret-key" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true @@ -25,7 +25,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true E2E_TEST_MODE = true SMTP_CONFIG = """ -{"test.example.com":{"host":"mailpit","port":1025,"secure":false}} +{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} """ [[kv_namespaces]] diff --git a/e2e/tests/api/admin-new-address.spec.ts b/e2e/tests/api/admin-new-address.spec.ts index 37e9c19a..87cbedff 100644 --- a/e2e/tests/api/admin-new-address.spec.ts +++ b/e2e/tests/api/admin-new-address.spec.ts @@ -16,4 +16,116 @@ test.describe('Admin New Address', () => { expect(body.address_id).toBeGreaterThan(0); expect(typeof body.address_id).toBe('number'); }); + + test('normalizes uppercase configured prefix and domain', async ({ request }) => { + const uniqueName = `admincase${Date.now()}`; + const res = await request.post(`${WORKER_URL}/admin/new_address`, { + data: { name: uniqueName, domain: TEST_DOMAIN.toUpperCase(), enablePrefix: true }, + }); + + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`); + expect(body.jwt).toBeTruthy(); + expect(body.address_id).toBeGreaterThan(0); + }); + + test('falls back to domains when default domains is empty', async ({ request }) => { + const uniqueName = `fallback${Date.now().toString(36)}`; + const res = await request.post(`${WORKER_URL}/api/new_address`, { + data: { name: uniqueName }, + }); + + expect(res.ok()).toBe(true); + const body = await res.json(); + + expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`); + expect(body.jwt).toBeTruthy(); + expect(body.address_id).toBeGreaterThan(0); + }); + + test('normalizes user role domains and prefix', async ({ request }) => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const email = `role-case-${suffix}@${TEST_DOMAIN}`; + const password = `role-pass-${suffix}`; + const name = `rolecase${suffix}`; + + const createUserRes = await request.post(`${WORKER_URL}/admin/users`, { + data: { email, password }, + }); + expect(createUserRes.ok()).toBe(true); + + const usersRes = await request.get(`${WORKER_URL}/admin/users`, { + params: { limit: '20', offset: '0', query: email }, + }); + expect(usersRes.ok()).toBe(true); + const usersBody = await usersRes.json(); + const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email); + expect(user).toBeTruthy(); + + const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, { + data: { user_id: user.id, role_text: 'case-role' }, + }); + expect(updateRoleRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt: userJwt } = await loginRes.json(); + + const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, { + headers: { 'x-user-token': userJwt }, + data: { name }, + }); + expect(createAddressRes.ok()).toBe(true); + const addressBody = await createAddressRes.json(); + + expect(addressBody.address).toBe(`role${name}@${TEST_DOMAIN}`); + expect(addressBody.jwt).toBeTruthy(); + expect(addressBody.address_id).toBeGreaterThan(0); + }); + + test('falls back to default domains when user role domains is empty', async ({ request }) => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const email = `empty-role-${suffix}@${TEST_DOMAIN}`; + const password = `empty-role-pass-${suffix}`; + const name = `emptyrole${suffix}`; + + const createUserRes = await request.post(`${WORKER_URL}/admin/users`, { + data: { email, password }, + }); + expect(createUserRes.ok()).toBe(true); + + const usersRes = await request.get(`${WORKER_URL}/admin/users`, { + params: { limit: '20', offset: '0', query: email }, + }); + expect(usersRes.ok()).toBe(true); + const usersBody = await usersRes.json(); + const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email); + expect(user).toBeTruthy(); + + const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, { + data: { user_id: user.id, role_text: 'empty-role' }, + }); + expect(updateRoleRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { + data: { email, password }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt: userJwt } = await loginRes.json(); + + const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, { + headers: { 'x-user-token': userJwt }, + data: { name }, + }); + expect(createAddressRes.ok()).toBe(true); + const addressBody = await createAddressRes.json(); + + expect(addressBody.address).toBe(`empty${name}@${TEST_DOMAIN}`); + expect(addressBody.jwt).toBeTruthy(); + expect(addressBody.address_id).toBeGreaterThan(0); + }); }); diff --git a/e2e/tests/api/email-forward-domain-normalization.spec.ts b/e2e/tests/api/email-forward-domain-normalization.spec.ts new file mode 100644 index 00000000..7f134c22 --- /dev/null +++ b/e2e/tests/api/email-forward-domain-normalization.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { + WORKER_URL, + TEST_DOMAIN, + createTestAddress, + deleteAddress, +} from '../../fixtures/test-helpers'; + +const ADMIN_PASSWORD = 'e2e-admin-pass'; +const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD }; + +const DEFAULT_ACCOUNT_SETTINGS = { + blockList: [], + sendBlockList: [], + verifiedAddressList: [], + fromBlockList: [], + noLimitSendAddressList: [], + emailRuleSettings: {}, + addressCreationSettings: {}, +}; + +async function resetAccountSettings(request: APIRequestContext) { + const res = await request.post(`${WORKER_URL}/admin/account_settings`, { + headers: ADMIN_HEADERS, + data: DEFAULT_ACCOUNT_SETTINGS, + }); + expect(res.ok()).toBe(true); +} + +test.describe('Email forward domain normalization', () => { + test.afterEach(async ({ request }) => { + await resetAccountSettings(request); + }); + + test('normalizes uppercase forwarding rule and recipient domains', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'forward-case'); + const forwardAddress = 'forward-target@test.example.com'; + + try { + const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, { + headers: ADMIN_HEADERS, + data: { + ...DEFAULT_ACCOUNT_SETTINGS, + emailRuleSettings: { + emailForwardingList: [{ + domains: [TEST_DOMAIN.toUpperCase()], + forward: forwardAddress, + }], + }, + }, + }); + expect(saveRes.ok()).toBe(true); + + const to = address.replace(`@${TEST_DOMAIN}`, `@${TEST_DOMAIN.toUpperCase()}`); + const subject = `forward-case-${Date.now()}`; + const raw = [ + `From: sender@test.example.com`, + `To: ${to}`, + `Subject: ${subject}`, + `Message-ID: <${subject}@test>`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=utf-8`, + ``, + `Forward domain normalization test`, + ].join('\r\n'); + + const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + data: { from: 'sender@test.example.com', to, raw }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.forwardedTo).toEqual([forwardAddress]); + + const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(mailsRes.ok()).toBe(true); + const mailsBody = await mailsRes.json(); + expect(mailsBody.results.some((mail: { address: string; raw: string }) => { + return mail.address === address && mail.raw.includes(subject); + })).toBe(true); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('does not forward when only the domain suffix string matches', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'forward-boundary'); + const forwardAddress = 'forward-boundary-target@test.example.com'; + + try { + const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, { + headers: ADMIN_HEADERS, + data: { + ...DEFAULT_ACCOUNT_SETTINGS, + emailRuleSettings: { + emailForwardingList: [{ + domains: [TEST_DOMAIN], + forward: forwardAddress, + }], + }, + }, + }); + expect(saveRes.ok()).toBe(true); + + const to = address.replace(`@${TEST_DOMAIN}`, `@evil${TEST_DOMAIN}`); + const subject = `forward-boundary-${Date.now()}`; + const raw = [ + `From: sender@test.example.com`, + `To: ${to}`, + `Subject: ${subject}`, + `Message-ID: <${subject}@test>`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=utf-8`, + ``, + `Forward domain boundary test`, + ].join('\r\n'); + + const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + data: { from: 'sender@test.example.com', to, raw }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.forwardedTo).toEqual([]); + } finally { + await deleteAddress(request, jwt); + } + }); + + test('keeps blank forwarding rule domain as catch-all', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'forward-catch-all'); + const forwardAddress = 'forward-catch-all-target@test.example.com'; + + try { + const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, { + headers: ADMIN_HEADERS, + data: { + ...DEFAULT_ACCOUNT_SETTINGS, + emailRuleSettings: { + emailForwardingList: [{ + domains: ['', 'not-the-domain.example.com'], + forward: forwardAddress, + }], + }, + }, + }); + expect(saveRes.ok()).toBe(true); + + const subject = `forward-catch-all-${Date.now()}`; + const raw = [ + `From: sender@test.example.com`, + `To: ${address}`, + `Subject: ${subject}`, + `Message-ID: <${subject}@test>`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=utf-8`, + ``, + `Forward catch-all domain test`, + ].join('\r\n'); + + const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + data: { from: 'sender@test.example.com', to: address, raw }, + }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.forwardedTo).toEqual([forwardAddress]); + } finally { + await deleteAddress(request, jwt); + } + }); +}); diff --git a/e2e/tests/api/health.spec.ts b/e2e/tests/api/health.spec.ts index 2701f748..5fbd82e0 100644 --- a/e2e/tests/api/health.spec.ts +++ b/e2e/tests/api/health.spec.ts @@ -15,6 +15,7 @@ test.describe('Health & Settings', () => { const settings = await res.json(); expect(settings.domains).toContain('test.example.com'); expect(settings.defaultDomains).toContain('test.example.com'); + expect(settings.prefix).toBe('tmp'); expect(settings.enableSendMail).toBe(true); expect(settings.enableUserCreateEmail).toBe(true); expect(settings.enableUserDeleteEmail).toBe(true); diff --git a/e2e/tests/api/send-mail-limit.spec.ts b/e2e/tests/api/send-mail-limit.spec.ts index 70a1a5ae..ec644b31 100644 --- a/e2e/tests/api/send-mail-limit.spec.ts +++ b/e2e/tests/api/send-mail-limit.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, APIRequestContext } from '@playwright/test'; +import { test, expect } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { WORKER_URL, WORKER_URL_SEND_MAIL_DOMAIN, @@ -425,11 +426,11 @@ test.describe('Send Mail Limit', () => { expect(res.status()).toBe(400); }); - test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => { + test('/admin/send_mail_by_binding normalizes uppercase allowed domain', async ({ request }) => { const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, { headers: ADMIN_HEADERS, data: { - from: 'admin@test.example.com', + from: 'admin@TEST.EXAMPLE.COM', to: ['recipient@test.example.com'], subject: `send-mail-domain-ok-${Date.now()}`, text: 'body', @@ -439,6 +440,20 @@ test.describe('Send Mail Limit', () => { expect(await res.json()).toEqual({ status: 'ok' }); }); + test('/admin/send_mail_by_binding normalizes object-form from domain', async ({ request }) => { + const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, { + headers: ADMIN_HEADERS, + data: { + from: { email: 'admin@TEST.EXAMPLE.COM', name: 'Admin' }, + to: ['recipient@test.example.com'], + subject: `send-mail-domain-object-ok-${Date.now()}`, + text: 'body', + }, + }); + expect(res.ok()).toBe(true); + expect(await res.json()).toEqual({ status: 'ok' }); + }); + test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => { const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, { headers: ADMIN_HEADERS, diff --git a/e2e/tests/api/user-domain-normalization.spec.ts b/e2e/tests/api/user-domain-normalization.spec.ts new file mode 100644 index 00000000..9ec72106 --- /dev/null +++ b/e2e/tests/api/user-domain-normalization.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import http from 'node:http'; +import { WORKER_URL } from '../../fixtures/test-helpers'; + +async function resetUserSettings(request: APIRequestContext) { + const res = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + enable: true, + enableMailVerify: false, + enableMailAllowList: false, + mailAllowList: [], + }, + }); + expect(res.ok()).toBe(true); +} + +async function resetOauth2Settings(request: APIRequestContext) { + const res = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, { + data: [], + }); + expect(res.ok()).toBe(true); +} + +async function startOauthServer(email: string): Promise<{ server: http.Server; baseUrl: string }> { + const server = http.createServer((req, res) => { + if (req.url === '/token') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'token', token_type: 'Bearer' })); + return; + } + if (req.url === '/userinfo') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ email })); + return; + } + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + }); + + await new Promise((resolve) => server.listen(0, '0.0.0.0', resolve)); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to resolve OAuth test server port'); + const hostname = process.env.CI ? 'e2e-runner' : 'localhost'; + return { server, baseUrl: `http://${hostname}:${addr.port}` }; +} + +test.describe('User domain normalization', () => { + test.afterEach(async ({ request }) => { + await resetOauth2Settings(request); + await resetUserSettings(request); + }); + + test('normalizes uppercase verify mail sender domain in admin settings', async ({ request }) => { + const res = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + enable: true, + enableMailVerify: true, + verifyMailSender: 'verify@TEST.EXAMPLE.COM', + }, + }); + expect(res.ok()).toBe(true); + }); + + test('normalizes uppercase user registration allow-list domains', async ({ request }) => { + const saveRes = await request.post(`${WORKER_URL}/admin/user_settings`, { + data: { + enable: true, + enableMailVerify: false, + enableMailAllowList: true, + mailAllowList: ['TEST.EXAMPLE.COM'], + }, + }); + expect(saveRes.ok()).toBe(true); + + const registerRes = await request.post(`${WORKER_URL}/user_api/register`, { + data: { + email: `allow-list-${Date.now()}@TEST.EXAMPLE.COM`, + password: 'allow-list-password', + }, + }); + expect(registerRes.ok()).toBe(true); + }); + + test('normalizes uppercase OAuth2 allow-list domains', async ({ request }) => { + const email = `oauth-allow-${Date.now()}@TEST.EXAMPLE.COM`; + const { server, baseUrl } = await startOauthServer(email); + + try { + const saveRes = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, { + data: [{ + name: 'case-oauth', + clientID: 'case-client', + clientSecret: 'case-secret', + authorizationURL: `${baseUrl}/authorize`, + accessTokenURL: `${baseUrl}/token`, + accessTokenFormat: 'json', + userInfoURL: `${baseUrl}/userinfo`, + redirectURL: `${baseUrl}/callback`, + userEmailKey: 'email', + scope: 'openid email', + enableMailAllowList: true, + mailAllowList: ['TEST.EXAMPLE.COM'], + }], + }); + expect(saveRes.ok()).toBe(true); + + const callbackRes = await request.post(`${WORKER_URL}/user_api/oauth2/callback`, { + data: { clientID: 'case-client', code: 'case-code' }, + }); + expect(callbackRes.ok()).toBe(true); + const body = await callbackRes.json(); + expect(body.jwt).toBeTruthy(); + } finally { + server.close(); + } + }); +}); diff --git a/e2e/tests/smtp-proxy/imap-proxy.spec.ts b/e2e/tests/smtp-proxy/imap-proxy.spec.ts index 0940a0fb..04448185 100644 --- a/e2e/tests/smtp-proxy/imap-proxy.spec.ts +++ b/e2e/tests/smtp-proxy/imap-proxy.spec.ts @@ -132,8 +132,10 @@ test.describe('IMAP Proxy', () => { try { const results = await client.search({ all: true }); expect(results.length).toBeGreaterThan(0); - const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true }); - expect(msg.uid).toBe(results[0]); + const seqMsg = await client.fetchOne(String(results[0]), { uid: true, flags: true }); + expect(seqMsg.uid).toBeGreaterThan(0); + const uidMsg = await client.fetchOne(String(seqMsg.uid), { uid: true, flags: true }, { uid: true }); + expect(uidMsg.uid).toBe(seqMsg.uid); } finally { lock.release(); } diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index cd3236ed..9c344a75 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -48,6 +48,8 @@ | `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` | > [!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. > @@ -137,7 +139,7 @@ > [!NOTE] USER_ROLES User Role Configuration > -> - If `domains` is empty, `DEFAULT_DOMAINS` will be used +> - If `domains` is empty, `DEFAULT_DOMAINS` will be used; if `DEFAULT_DOMAINS` is also empty, it falls back to `DOMAINS` > - If prefix is null, the default prefix will be used, if prefix is an empty string, no prefix will be used > > When deploying through UI, configure `USER_ROLES` in this format: `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]` diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index d67ccda2..fa43f9e7 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -48,6 +48,8 @@ | `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` | > [!NOTE] +> `DEFAULT_DOMAINS` 未配置或配置为空数组时,会回退使用 `DOMAINS`。 +> > `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare > 侧的子域名路由。 > @@ -131,7 +133,7 @@ > [!NOTE] USER_ROLES 用户角色配置说明 > -> - 如果 `domains` 为空将使用 `DEFAULT_DOMAINS` +> - 如果 `domains` 为空将使用 `DEFAULT_DOMAINS`;如果 `DEFAULT_DOMAINS` 也为空,则继续回退到 `DOMAINS` > - 如果 prefix 为 null 将使用默认前缀, 如果 prefix 为空字符串将不使用前缀 > > 通过用户界面部署时 `USER_ROLES` 请配置为此格式 `[{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"vip","prefix":"vip"},{"domains":["awsl.uk","dreamhunter2333.xyz"],"role":"admin","prefix":""}]` diff --git a/worker/src/admin_api/admin_user_api.ts b/worker/src/admin_api/admin_user_api.ts index c7e70e7d..1a50b992 100644 --- a/worker/src/admin_api/admin_user_api.ts +++ b/worker/src/admin_api/admin_user_api.ts @@ -1,7 +1,7 @@ import { Context } from 'hono'; import { CONSTANTS } from '../constants'; -import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles } from '../utils'; +import { getJsonSetting, saveSetting, checkUserPassword, getDomains, getUserRoles, getMailDomain, includesDomain } from '../utils'; import { UserSettings, GeoData, UserInfo, RoleAddressConfig } from "../models"; import { handleListQuery } from '../common' import UserBindAddressModule from '../user_api/bind_address'; @@ -24,9 +24,9 @@ export default { return c.text(msgs.VerifyMailSenderNotSetMsg, 400) } if (settings.enableMailVerify && settings.verifyMailSender) { - const mailDomain = settings.verifyMailSender.split("@")[1]; + const mailDomain = getMailDomain(settings.verifyMailSender); const domains = getDomains(c); - if (!domains.includes(mailDomain)) { + if (!includesDomain(domains, mailDomain)) { return c.text(`${msgs.VerifyMailDomainInvalidMsg} ${JSON.stringify(domains, null, 2)}`, 400) } } diff --git a/worker/src/admin_api/e2e_test_api.ts b/worker/src/admin_api/e2e_test_api.ts index d0280e3b..4c9b859b 100644 --- a/worker/src/admin_api/e2e_test_api.ts +++ b/worker/src/admin_api/e2e_test_api.ts @@ -44,19 +44,24 @@ const receiveMail = async (c: Context) => { if (!headers.has('Message-ID')) headers.set('Message-ID', ``); const rawBytes = new TextEncoder().encode(raw); - const state = { rejected: undefined as string | undefined, replyCalled: false }; + const state = { rejected: undefined as string | undefined, replyCalled: false, forwardedTo: [] as string[] }; const mockMessage: ForwardableEmailMessage = { from, to, headers, rawSize: rawBytes.byteLength, raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }), setReject(reason: string) { state.rejected = reason; }, - forward: async () => ({ messageId: '' }), + forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; }, reply: async () => { state.replyCalled = true; return { messageId: '' }; }, }; const { email: emailHandler } = await import('../email'); await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} }); - return c.json({ success: !state.rejected, replyCalled: state.replyCalled, ...(state.rejected ? { rejected: state.rejected } : {}) }); + return c.json({ + success: !state.rejected, + replyCalled: state.replyCalled, + forwardedTo: state.forwardedTo, + ...(state.rejected ? { rejected: state.rejected } : {}) + }); }; export default { seedMail, receiveMail }; diff --git a/worker/src/admin_api/send_mail.ts b/worker/src/admin_api/send_mail.ts index 4a8aca3a..7d744ad8 100644 --- a/worker/src/admin_api/send_mail.ts +++ b/worker/src/admin_api/send_mail.ts @@ -3,6 +3,7 @@ import { isSendMailBindingEnabled } from "../common"; import i18n from "../i18n"; import { sendMail } from "../mails_api/send_mail_api"; import { ensureSendMailLimit, increaseSendMailLimitCount } from "../mails_api/send_mail_limit_utils"; +import { getMailDomain } from "../utils"; const getAdminSendMailErrorMessage = ( msgs: ReturnType, @@ -68,9 +69,7 @@ export const sendMailByBindingAdmin = async (c: Context) => { return c.text(msgs.InvalidInputMsg, 400) } const fromMail = typeof from === "string" ? from : from?.email; - const mailDomain = typeof fromMail === "string" && fromMail.includes("@") - ? fromMail.split("@")[1]?.trim().toLowerCase() - : null; + const mailDomain = getMailDomain(fromMail); if (!mailDomain) { return c.text(msgs.InvalidInputMsg, 400) } diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts index 142c66d1..2febe7eb 100644 --- a/worker/src/commom_api.ts +++ b/worker/src/commom_api.ts @@ -25,7 +25,7 @@ api.get('/open_api/settings', async (c) => { "title": c.env.TITLE, "announcement": utils.getStringValue(c.env.ANNOUNCEMENT), "alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT), - "prefix": utils.getStringValue(c.env.PREFIX), + "prefix": utils.trimLower(c.env.PREFIX), "addressRegex": utils.getStringValue(c.env.ADDRESS_REGEX), "minAddressLen": utils.getIntValue(c.env.MIN_ADDRESS_LEN, 1), "maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30), diff --git a/worker/src/common.ts b/worker/src/common.ts index 9e3b3c90..3b6a711a 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -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 } from './utils'; +import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, normalizeDomains, trimLower } from './utils'; import { unbindTelegramByAddress } from './telegram_api/common'; import { CONSTANTS } from './constants'; import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models'; @@ -15,7 +15,7 @@ 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(); + return trimLower(domain); } const isValidDomainLabel = (label: string): boolean => { @@ -41,7 +41,7 @@ export const isSendMailEnabled = ( // Check SMTP config for domain const smtpConfigMap = getJsonObjectValue>(c.env.SMTP_CONFIG); - if (smtpConfigMap && smtpConfigMap[mailDomain]) return true; + if (getDomainMapValue(smtpConfigMap, mailDomain)) return true; // Check SEND_MAIL binding if (isSendMailBindingEnabled(c, mailDomain)) return true; @@ -56,8 +56,7 @@ export const isSendMailBindingEnabled = ( if (!c.env.SEND_MAIL) { return false; } - const sendMailDomains = getStringArray(c.env.SEND_MAIL_DOMAINS) - .map((domain) => normalizeDomainValue(domain)); + const sendMailDomains = normalizeDomains(getStringArray(c.env.SEND_MAIL_DOMAINS)); if (sendMailDomains.length === 0) { return true; } @@ -369,9 +368,9 @@ export const newAddress = async ( } // create address with prefix if (typeof addressPrefix === "string") { - name = addressPrefix.trim() + name; + name = trimLower(addressPrefix) + name; } else if (enablePrefix) { - name = getStringValue(c.env.PREFIX).trim() + name; + name = trimLower(c.env.PREFIX) + name; } // check domain const allowDomains = checkAllowDomains ? await getAllowDomains(c) : getDomains(c); @@ -760,13 +759,13 @@ export const commonGetUserRole = async ( export const getAddressPrefix = async (c: Context): Promise => { const user = c.get("userPayload"); if (!user) { - return getStringValue(c.env.PREFIX).trim().toLowerCase(); + return trimLower(c.env.PREFIX); } const user_role = await commonGetUserRole(c, user.user_id); if (typeof user_role?.prefix === "string") { - return user_role.prefix.trim().toLowerCase(); + return trimLower(user_role.prefix); } - return getStringValue(c.env.PREFIX).trim().toLowerCase(); + return trimLower(c.env.PREFIX); } export const getAllowDomains = async (c: Context): Promise => { @@ -775,7 +774,10 @@ export const getAllowDomains = async (c: Context): Promise 0) { + return normalizeDomains(user_role.domains); + } + return getDefaultDomains(c); } export async function sendWebhook( diff --git a/worker/src/email/auto_reply.ts b/worker/src/email/auto_reply.ts index b20c553f..31bc4ca9 100644 --- a/worker/src/email/auto_reply.ts +++ b/worker/src/email/auto_reply.ts @@ -1,5 +1,5 @@ import { createMimeMessage } from "mimetext"; -import { getBooleanValue } from "../utils"; +import { getBooleanValue, normalizeAddressDomain } from "../utils"; /** * Check if the sender matches the source_prefix filter. @@ -21,14 +21,18 @@ function matchSender(from: string, sourcePrefix: string | undefined): boolean { return from.startsWith(sourcePrefix); } -export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise => { +export const auto_reply = async ( + message: ForwardableEmailMessage, + env: Bindings, + toAddress: string = normalizeAddressDomain(message.to) +): Promise => { const message_id = message.headers.get("Message-ID"); // auto reply email if (getBooleanValue(env.ENABLE_AUTO_REPLY) && message_id) { try { const results = await env.DB.prepare( `SELECT * FROM auto_reply_mails where address = ? and enabled = 1` - ).bind(message.to).first>(); + ).bind(toAddress).first>(); if (results && matchSender(message.from, results.source_prefix)) { if (!results.subject || !results.message) { console.log("auto-reply using defaults:", !results.subject ? "subject" : "", !results.message ? "message" : ""); @@ -50,7 +54,7 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings } else { const { EmailMessage } = await import('cloudflare:email'); const replyMessage = new EmailMessage( - message.to, + toAddress, message.from, msg.asRaw() ); diff --git a/worker/src/email/forward.ts b/worker/src/email/forward.ts index 82434777..f4db302d 100644 --- a/worker/src/email/forward.ts +++ b/worker/src/email/forward.ts @@ -1,6 +1,6 @@ import { Context } from "hono"; -import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils"; +import { getEnvStringList, getJsonObjectValue, getJsonSetting, getMailDomain, isDomainOrSubdomain, normalizeDomain } from "../utils"; import { EmailRuleSettings } from "../models"; import { CONSTANTS } from "../constants"; @@ -91,6 +91,7 @@ async function forwardByRules( ...(emailRuleSettings?.emailForwardingList || []), ]; + const messageDomain = getMailDomain(message.to); for (const rule of allRules) { // 检查来源地址是否匹配正则 if (!matchSourcePatterns(message.from, rule.sourcePatterns, rule.sourceMatchMode)) { @@ -100,8 +101,16 @@ async function forwardByRules( // 检查目标地址是否匹配域名,并转发 // 保持原始逻辑:每个匹配的 domain 都会触发一次转发 if (rule.domains && rule.domains.length > 0) { - for (const domain of rule.domains) { - if (message.to.endsWith(domain) && rule.forward) { + const normalizedDomains = rule.domains.map(normalizeDomain); + if (normalizedDomains.some(domain => domain.length === 0)) { + if (rule.forward) { + await message.forward(rule.forward); + } + continue; + } + + for (const normalizedDomain of normalizedDomains) { + if (isDomainOrSubdomain(messageDomain, normalizedDomain) && rule.forward) { await message.forward(rule.forward); } } diff --git a/worker/src/email/index.ts b/worker/src/email/index.ts index 3a67ee44..128e2a7b 100644 --- a/worker/src/email/index.ts +++ b/worker/src/email/index.ts @@ -1,6 +1,6 @@ import { Context } from "hono"; -import { getBooleanValue, getJsonSetting } from "../utils"; +import { getBooleanValue, getJsonSetting, normalizeAddressDomain } from "../utils"; import { sendMailToTelegram } from "../telegram_api"; import { auto_reply } from "./auto_reply"; import { isBlocked } from "./black_list"; @@ -15,9 +15,10 @@ import { compressText } from "../gzip"; async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) { + const toAddress = normalizeAddressDomain(message.to); if (await isBlocked(message.from, env)) { message.setReject("Reject from address"); - console.log(`Reject message from ${message.from} to ${message.to}`); + console.log(`Reject message from ${message.from} to ${toAddress}`); return; } const rawEmail = await new Response(message.raw).text(); @@ -27,10 +28,10 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu // check if junk mail try { - const is_junk = await check_if_junk_mail(env, message.to, parsedEmailContext, message.headers.get("Message-ID")); + const is_junk = await check_if_junk_mail(env, toAddress, parsedEmailContext, message.headers.get("Message-ID")); if (is_junk) { message.setReject("Junk mail"); - console.log(`Junk mail from ${message.from} to ${message.to}`); + console.log(`Junk mail from ${message.from} to ${toAddress}`); return; } } catch (error) { @@ -45,10 +46,10 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu if (emailRuleSettings?.blockReceiveUnknowAddressEmail) { const db_address_id = await env.DB.prepare( `SELECT id FROM address where name = ? ` - ).bind(message.to).first("id"); + ).bind(toAddress).first("id"); if (!db_address_id) { message.setReject("Unknown address"); - console.log(`Unknown address mail from ${message.from} to ${message.to}`); + console.log(`Unknown address mail from ${message.from} to ${toAddress}`); return; } } @@ -58,7 +59,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu // remove attachment if configured or size > 2MB try { - await remove_attachment_if_need(env, parsedEmailContext, message.from, message.to, message.rawSize); + await remove_attachment_if_need(env, parsedEmailContext, message.from, toAddress, message.rawSize); } catch (error) { console.error("remove attachment error", error); } @@ -79,7 +80,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu ({ success } = await env.DB.prepare( `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)` ).bind( - message.from, message.to, compressed, message_id + message.from, toAddress, compressed, message_id ).run()); } catch (dbError) { // Fallback to plaintext only if raw_blob column is missing (migration not applied) @@ -89,7 +90,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu ({ success } = await env.DB.prepare( `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)` ).bind( - message.from, message.to, parsedEmailContext.rawEmail, message_id + message.from, toAddress, parsedEmailContext.rawEmail, message_id ).run()); } else { throw dbError; @@ -99,19 +100,19 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu ({ success } = await env.DB.prepare( `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)` ).bind( - message.from, message.to, parsedEmailContext.rawEmail, message_id + message.from, toAddress, parsedEmailContext.rawEmail, message_id ).run()); } } else { ({ success } = await env.DB.prepare( `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)` ).bind( - message.from, message.to, parsedEmailContext.rawEmail, message_id + message.from, toAddress, parsedEmailContext.rawEmail, message_id ).run()); } if (!success) { - message.setReject(`Failed save message to ${message.to}`); - console.error(`Failed save message from ${message.from} to ${message.to}`); + message.setReject(`Failed save message to ${toAddress}`); + console.error(`Failed save message from ${message.from} to ${toAddress}`); } } catch (error) { @@ -125,7 +126,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu try { await sendMailToTelegram( { env: env } as Context, - message.to, parsedEmailContext, message_id); + toAddress, parsedEmailContext, message_id); } catch (error) { console.error("send mail to telegram error", error); } @@ -134,7 +135,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu try { await triggerWebhook( { env: env } as Context, - message.to, parsedEmailContext, message_id + toAddress, parsedEmailContext, message_id ); } catch (error) { console.error("send webhook error", error); @@ -146,7 +147,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu const parsedText = parsedEmail?.text ?? "" const rpcEmail: RPCEmailMessage = { from: message.from, - to: message.to, + to: toAddress, rawEmail: rawEmail, headers: message.headers } @@ -156,10 +157,10 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu } // auto reply email - await auto_reply(message, env); + await auto_reply(message, env, toAddress); // AI email content extraction - await extractEmailInfo(parsedEmailContext, env, message_id, message.to); + await extractEmailInfo(parsedEmailContext, env, message_id, toAddress); } export { email } diff --git a/worker/src/mails_api/send_mail_api.ts b/worker/src/mails_api/send_mail_api.ts index 322725f5..719ee0fa 100644 --- a/worker/src/mails_api/send_mail_api.ts +++ b/worker/src/mails_api/send_mail_api.ts @@ -6,7 +6,7 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer'; import i18n from '../i18n'; import { CONSTANTS } from '../constants' -import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue } from '../utils'; +import { getJsonSetting, getDomains, getBooleanValue, getJsonObjectValue, getDomainMapValue, getMailDomain, includesDomain } from '../utils'; import { GeoData } from '../models' import { handleListQuery, isSendMailBindingEnabled, updateAddressUpdatedAt } from '../common' import { getSendBalanceState, requestSendMailAccess } from './send_balance'; @@ -81,7 +81,7 @@ const sendMailByResend = async ( subject: string, content: string, is_html: boolean } ): Promise => { - const mailDomain = address.split("@")[1]; + const mailDomain = getMailDomain(address); const token = c.env[ `RESEND_TOKEN_${mailDomain.replace(/\./g, "_").toUpperCase()}` ] || c.env.RESEND_TOKEN; @@ -143,9 +143,9 @@ export const sendMail = async ( throw new Error(msgs.AddressNotFoundMsg) } // check domain - const mailDomain = address.split("@")[1]; + const mailDomain = getMailDomain(address); const domains = getDomains(c); - if (!domains.includes(mailDomain)) { + if (!includesDomain(domains, mailDomain)) { throw new Error(msgs.InvalidDomainMsg) } const sendBalanceState = await getSendBalanceState(c, address, { @@ -182,7 +182,7 @@ export const sendMail = async ( ]; // send by smtp const smtpConfigMap = getJsonObjectValue>(c.env.SMTP_CONFIG); - const smtpConfig = smtpConfigMap ? smtpConfigMap[mailDomain] : null; + const smtpConfig = getDomainMapValue(smtpConfigMap, mailDomain); // send by verified address list let sendByVerifiedAddressList = false; if (c.env.SEND_MAIL) { diff --git a/worker/src/telegram_api/telegram.ts b/worker/src/telegram_api/telegram.ts index 927f1f6e..94550032 100644 --- a/worker/src/telegram_api/telegram.ts +++ b/worker/src/telegram_api/telegram.ts @@ -4,7 +4,7 @@ import { Telegraf, Context as TgContext, Markup } from "telegraf"; import { callbackQuery } from "telegraf/filters"; import { CONSTANTS } from "../constants"; -import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils'; +import { getBooleanValue, getDomains, getJsonObjectValue, trimLower } from '../utils'; import { TelegramSettings } from "./settings"; import { sendTelegramAttachments } from "./tg_file_upload"; import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common"; @@ -117,7 +117,7 @@ export function newTelegramBot(c: Context, token: string): Teleg bot.command("start", async (ctx: TgContext) => { const msgs = await getTgMessages(c, ctx); - const prefix = getStringValue(c.env.PREFIX) + const prefix = trimLower(c.env.PREFIX) const domains = getDomains(c); const commands = getTelegramCommands(c); return await ctx.reply( diff --git a/worker/src/user_api/oauth2.ts b/worker/src/user_api/oauth2.ts index 65dc6d6b..f52bb075 100644 --- a/worker/src/user_api/oauth2.ts +++ b/worker/src/user_api/oauth2.ts @@ -2,7 +2,7 @@ import { Context } from 'hono'; import { Jwt } from 'hono/utils/jwt' import i18n from '../i18n'; -import { getJsonSetting, getStringValue, getUserRoles } from '../utils'; +import { getJsonSetting, getMailDomain, getStringValue, getUserRoles, includesDomain } from '../utils'; import { UserOauth2Settings } from '../models'; import { CONSTANTS } from '../constants'; @@ -107,8 +107,8 @@ export default { return c.text(msgs.Oauth2FailedGetUserEmailMsg, 400); } // check email in mail allow list - const mailDomain = email.split("@")[1]; - if (setting.enableMailAllowList && !setting.mailAllowList?.includes(mailDomain)) { + const mailDomain = getMailDomain(email); + if (setting.enableMailAllowList && !includesDomain(setting.mailAllowList, mailDomain)) { return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(setting.mailAllowList, null, 2)}`, 400) } // insert or update user diff --git a/worker/src/user_api/user.ts b/worker/src/user_api/user.ts index 780dc465..bf31f8f3 100644 --- a/worker/src/user_api/user.ts +++ b/worker/src/user_api/user.ts @@ -2,7 +2,7 @@ import { Context } from 'hono'; import { Jwt } from 'hono/utils/jwt' import i18n from '../i18n'; -import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils" +import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue, getMailDomain, includesDomain } from "../utils" import { CONSTANTS } from "../constants"; import { GeoData, UserInfo, UserSettings } from "../models"; import { sendMail } from "../mails_api/send_mail_api"; @@ -20,10 +20,10 @@ export default { const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY); const settings = new UserSettings(value) // check mail domain allow list - const mailDomain = email.split("@")[1]; + const mailDomain = getMailDomain(email); if (settings.enableMailAllowList && settings.mailAllowList - && !settings.mailAllowList.includes(mailDomain) + && !includesDomain(settings.mailAllowList, mailDomain) ) { return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400) } @@ -95,10 +95,10 @@ export default { return c.text(msgs.InvalidVerifyCodeMsg, 400) } // check mail domain allow list - const mailDomain = email.split("@")[1]; + const mailDomain = getMailDomain(email); if (settings.enableMailAllowList && settings.mailAllowList - && !settings.mailAllowList.includes(mailDomain) + && !includesDomain(settings.mailAllowList, mailDomain) ) { return c.text(`${msgs.UserMailDomainMustInMsg} ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400) } diff --git a/worker/src/utils.ts b/worker/src/utils.ts index 9cd278bf..0c701d16 100644 --- a/worker/src/utils.ts +++ b/worker/src/utils.ts @@ -137,12 +137,97 @@ export const getStringArray = ( return value; } +export const trimLower = ( + value: string | undefined | null +): string => { + return getStringValue(value).trim().toLowerCase(); +} + +export const normalizeDomain = ( + value: string | undefined | null +): string => { + return trimLower(value); +} + +export const normalizeDomains = (domains: string[]): string[] => { + return domains + .map((domain) => normalizeDomain(domain)) + .filter((domain) => domain.length > 0); +} + +export const getMailDomain = ( + value: string | undefined | null +): string => { + const address = getStringValue(value).trim(); + const atIndex = address.lastIndexOf("@"); + if (atIndex < 0) { + return ""; + } + return normalizeDomain(address.slice(atIndex + 1)); +} + +export const normalizeAddressDomain = ( + value: string | undefined | null +): string => { + const address = getStringValue(value).trim(); + const atIndex = address.lastIndexOf("@"); + if (atIndex < 0) { + return address; + } + const localPart = address.slice(0, atIndex).trim(); + const domain = normalizeDomain(address.slice(atIndex + 1)); + if (!localPart || !domain) { + return address; + } + return `${localPart}@${domain}`; +} + +export const includesDomain = ( + domains: string[] | undefined | null, + domain: string | undefined | null +): boolean => { + const normalizedDomain = normalizeDomain(domain); + if (!normalizedDomain || !domains || domains.length === 0) { + return false; + } + return normalizeDomains(domains).includes(normalizedDomain); +} + +export const isDomainOrSubdomain = ( + domain: string | undefined | null, + allowDomain: string | undefined | null +): boolean => { + const normalizedDomain = normalizeDomain(domain); + const normalizedAllowDomain = normalizeDomain(allowDomain); + if (!normalizedDomain || !normalizedAllowDomain) { + return false; + } + return normalizedDomain === normalizedAllowDomain + || normalizedDomain.endsWith(`.${normalizedAllowDomain}`); +} + +export const getDomainMapValue = ( + valueMap: Record | undefined | null, + domain: string | undefined | null +): T | null => { + const normalizedDomain = normalizeDomain(domain); + if (!normalizedDomain || !valueMap) { + return null; + } + for (const [key, value] of Object.entries(valueMap)) { + if (normalizeDomain(key) === normalizedDomain) { + return value; + } + } + return null; +} + export const getDefaultDomains = (c: Context): string[] => { if (c.env.DEFAULT_DOMAINS == undefined || c.env.DEFAULT_DOMAINS == null) { return getDomains(c); } - const domains = getStringArray(c.env.DEFAULT_DOMAINS); - return domains || getDomains(c); + const domains = normalizeDomains(getStringArray(c.env.DEFAULT_DOMAINS)); + return domains.length > 0 ? domains : getDomains(c); } export const getDomains = (c: Context): string[] => { @@ -152,36 +237,46 @@ export const getDomains = (c: Context): string[] => { // check if DOMAINS is an array, if not use json.parse if (!Array.isArray(c.env.DOMAINS)) { try { - return JSON.parse(c.env.DOMAINS); + return normalizeDomains(JSON.parse(c.env.DOMAINS)); } catch (e) { console.error("Failed to parse DOMAINS", e); return []; } } - return c.env.DOMAINS; + return normalizeDomains(c.env.DOMAINS); } export const getRandomSubdomainDomains = (c: Context): string[] => { if (!c.env.RANDOM_SUBDOMAIN_DOMAINS) { return []; } - return getStringArray(c.env.RANDOM_SUBDOMAIN_DOMAINS); + return normalizeDomains(getStringArray(c.env.RANDOM_SUBDOMAIN_DOMAINS)); } export const getUserRoles = (c: Context): UserRole[] => { if (!c.env.USER_ROLES) { return []; } + const normalizeRoles = (roles: UserRole[]): UserRole[] => { + return roles.map((role) => ({ + ...role, + domains: Array.isArray(role.domains) + ? normalizeDomains(role.domains) + : typeof role.domains === "string" + ? normalizeDomains([role.domains]) + : role.domains, + })); + }; // check if USER_ROLES is an array, if not use json.parse if (!Array.isArray(c.env.USER_ROLES)) { try { - return JSON.parse(c.env.USER_ROLES); + return normalizeRoles(JSON.parse(c.env.USER_ROLES)); } catch (e) { console.error("Failed to parse USER_ROLES", e); return []; } } - return c.env.USER_ROLES; + return normalizeRoles(c.env.USER_ROLES); } export const getAnotherWorkerList = (c: Context): AnotherWorker[] => { @@ -414,6 +509,13 @@ export default { getBooleanValue, getIntValue, getStringArray, + trimLower, + normalizeDomain, + normalizeDomains, + getMailDomain, + normalizeAddressDomain, + includesDomain, + getDomainMapValue, getDefaultDomains, getDomains, getRandomSubdomainDomains, diff --git a/worker/src/worker.ts b/worker/src/worker.ts index 62e501fe..2ee188fc 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -14,7 +14,7 @@ import { api as telegramApi } from './telegram_api' import i18n from './i18n'; import { email } from './email'; import { scheduled } from './scheduled'; -import { getPasswords, getBooleanValue, getStringArray, checkIsAdmin } from './utils'; +import { getPasswords, getBooleanValue, getDomains, checkIsAdmin } from './utils'; import { checkAccessControl } from './ip_blacklist'; const API_PATHS = [ @@ -270,7 +270,7 @@ const health_check = async (c: Context) => { if (!c.env.JWT_SECRET) { return c.text(msgs.JWTSecretNotSetMsg, 400); } - if (getStringArray(c.env.DOMAINS).length === 0) { + if (getDomains(c).length === 0) { return c.text(msgs.DomainsNotSetMsg, 400); } return c.text("OK");