mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-26 02:29:49 +08:00
fix: normalize domain casing
Fix domain casing normalization for configured domains and inbound recipient domains.
This commit is contained in:
@@ -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 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
175
e2e/tests/api/email-forward-domain-normalization.spec.ts
Normal file
175
e2e/tests/api/email-forward-domain-normalization.spec.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
118
e2e/tests/api/user-domain-normalization.spec.ts
Normal file
118
e2e/tests/api/user-domain-normalization.spec.ts
Normal file
@@ -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<void>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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":""}]`
|
||||
|
||||
@@ -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":""}]`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,19 +44,24 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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<typeof i18n.getMessagesbyContext>,
|
||||
@@ -68,9 +69,7 @@ export const sendMailByBindingAdmin = async (c: Context<HonoCustomType>) => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<string, WorkerMailerOptions>>(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<HonoCustomType>): Promise<string | undefined> => {
|
||||
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<HonoCustomType>): Promise<string[]> => {
|
||||
@@ -775,7 +774,10 @@ export const getAllowDomains = async (c: Context<HonoCustomType>): Promise<strin
|
||||
return getDefaultDomains(c);
|
||||
}
|
||||
const user_role = await commonGetUserRole(c, user.user_id);
|
||||
return user_role?.domains || getDefaultDomains(c);;
|
||||
if (user_role?.domains && user_role.domains.length > 0) {
|
||||
return normalizeDomains(user_role.domains);
|
||||
}
|
||||
return getDefaultDomains(c);
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
|
||||
@@ -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<void> => {
|
||||
export const auto_reply = async (
|
||||
message: ForwardableEmailMessage,
|
||||
env: Bindings,
|
||||
toAddress: string = normalizeAddressDomain(message.to)
|
||||
): Promise<void> => {
|
||||
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<Record<string, string>>();
|
||||
).bind(toAddress).first<Record<string, string>>();
|
||||
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()
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HonoCustomType>,
|
||||
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<HonoCustomType>,
|
||||
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 }
|
||||
|
||||
@@ -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<void> => {
|
||||
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<Record<string, WorkerMailerOptions>>(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) {
|
||||
|
||||
@@ -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<HonoCustomType>, 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = <T>(
|
||||
valueMap: Record<string, T> | 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<HonoCustomType>): 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<HonoCustomType>): string[] => {
|
||||
@@ -152,36 +237,46 @@ export const getDomains = (c: Context<HonoCustomType>): 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<HonoCustomType>): 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<HonoCustomType>): 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<HonoCustomType>): AnotherWorker[] => {
|
||||
@@ -414,6 +509,13 @@ export default {
|
||||
getBooleanValue,
|
||||
getIntValue,
|
||||
getStringArray,
|
||||
trimLower,
|
||||
normalizeDomain,
|
||||
normalizeDomains,
|
||||
getMailDomain,
|
||||
normalizeAddressDomain,
|
||||
includesDomain,
|
||||
getDomainMapValue,
|
||||
getDefaultDomains,
|
||||
getDomains,
|
||||
getRandomSubdomainDomains,
|
||||
|
||||
@@ -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<HonoCustomType>) => {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user