From 15e339282d267f8d151d1435cf7ee920bc17479d Mon Sep 17 00:00:00 2001 From: jiaxin Date: Tue, 14 Apr 2026 15:25:39 +0800 Subject: [PATCH] fix: respect user mail deletion toggle in user center (#979) * fix: respect user mail deletion toggle in user center Hide user mailbox delete actions and block /user_api/mails deletion when ENABLE_USER_DELETE_EMAIL is disabled. Add an e2e regression test and changelog entries for issue #978. * test: hash user password in mail deletion e2e Use the same SHA-256 pre-hashed password format as the frontend for the user register/login flow in the mail deletion regression test. --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/fixtures/wrangler.toml.e2e.env-off | 2 +- e2e/tests/api/mail-deletion.spec.ts | 103 +++++++++++++++++++++++- frontend/src/views/user/UserMailBox.vue | 4 +- worker/src/user_api/user_mail_api.ts | 6 ++ 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831f2b5f..0b3871dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Bug Fixes +- fix: |用户侧收件箱| 修复 `ENABLE_USER_DELETE_EMAIL` 关闭时用户中心仍显示删除按钮且仍可通过 `/user_api/mails/:id` 删除邮件的问题(#978) - fix: |Admin| 修复 `/admin/address` 与 `/admin/users` 在使用完整邮箱(query 长度超过 50 字节)作为搜索条件时报错 `D1_ERROR: LIKE or GLOB pattern too complex` 的问题,长查询自动改用 `instr()` 绕开 D1 的 LIKE pattern 长度限制(#956) ### Improvements diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index a78a4f10..46c4048e 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -15,6 +15,7 @@ ### Bug Fixes +- fix: |User Mailbox| Fix an issue where the user center still showed delete actions and could still delete mail via `/user_api/mails/:id` when `ENABLE_USER_DELETE_EMAIL` was disabled (#978) - fix: |Admin| Fix `D1_ERROR: LIKE or GLOB pattern too complex` on `/admin/address` and `/admin/users` when searching by full email address (query length pushes the LIKE pattern over D1's 50-byte limit). Long queries now fall back to `instr()` to bypass the LIKE pattern length cap (#956) ### Improvements diff --git a/e2e/fixtures/wrangler.toml.e2e.env-off b/e2e/fixtures/wrangler.toml.e2e.env-off index 17b0acb6..e21f2e11 100644 --- a/e2e/fixtures/wrangler.toml.e2e.env-off +++ b/e2e/fixtures/wrangler.toml.e2e.env-off @@ -12,7 +12,7 @@ ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false JWT_SECRET = "e2e-test-secret-key-env-off" BLACK_LIST = "" ENABLE_USER_CREATE_EMAIL = true -ENABLE_USER_DELETE_EMAIL = true +ENABLE_USER_DELETE_EMAIL = false ENABLE_AUTO_REPLY = true DEFAULT_SEND_BALANCE = 10 ENABLE_ADDRESS_PASSWORD = true diff --git a/e2e/tests/api/mail-deletion.spec.ts b/e2e/tests/api/mail-deletion.spec.ts index 852ae701..dc914a3a 100644 --- a/e2e/tests/api/mail-deletion.spec.ts +++ b/e2e/tests/api/mail-deletion.spec.ts @@ -1,7 +1,108 @@ +import { createHash } from 'node:crypto'; import { test, expect } from '@playwright/test'; -import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers'; +import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers'; test.describe('Mail Deletion', () => { + test('user mail deletion is disabled when ENABLE_USER_DELETE_EMAIL is false', async ({ request }) => { + test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured'); + + const testUserEmail = `mail-delete-e2e-${Date.now()}@test.example.com`; + const testUserPassword = 'test-password-123'; + const testUserPasswordHash = createHash('sha256').update(testUserPassword).digest('hex'); + + const enableRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/user_settings`, { + data: { + enable: true, + enableMailVerify: false, + }, + }); + expect(enableRes.ok()).toBe(true); + + const registerRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/register`, { + data: { email: testUserEmail, password: testUserPasswordHash }, + }); + expect(registerRes.ok()).toBe(true); + + const loginRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/login`, { + data: { email: testUserEmail, password: testUserPasswordHash }, + }); + expect(loginRes.ok()).toBe(true); + const { jwt: userJwt } = await loginRes.json(); + expect(userJwt).toBeTruthy(); + + const createRes = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, { + data: { + name: `user-del-disabled${Date.now()}`, + domain: 'test.example.com', + }, + }); + expect(createRes.ok()).toBe(true); + const { jwt, address, address_id } = await createRes.json(); + + try { + const bindRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/bind_address`, { + headers: { + Authorization: `Bearer ${jwt}`, + 'x-user-token': userJwt, + }, + }); + expect(bindRes.ok()).toBe(true); + + const from = 'sender@test.example.com'; + const subject = 'Disabled Mail Delete'; + const boundary = `----E2E${Date.now()}`; + const raw = [ + `From: ${from}`, + `To: ${address}`, + `Subject: ${subject}`, + `Message-ID: `, + 'MIME-Version: 1.0', + `Content-Type: multipart/alternative; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=utf-8', + '', + 'Hello from E2E', + `--${boundary}`, + 'Content-Type: text/html; charset=utf-8', + '', + '

Hello from E2E

', + `--${boundary}--`, + ].join('\r\n'); + + const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, { + data: { from, to: address, raw }, + }); + expect(seedRes.ok()).toBe(true); + const seedBody = await seedRes.json(); + expect(seedBody.success).toBe(true); + + const listRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, { + headers: { 'x-user-token': userJwt }, + }); + expect(listRes.ok()).toBe(true); + const { results } = await listRes.json(); + expect(results).toHaveLength(1); + + const targetId = results[0].id; + const delRes = await request.delete(`${WORKER_URL_ENV_OFF}/user_api/mails/${targetId}`, { + headers: { 'x-user-token': userJwt }, + }); + expect(delRes.status()).toBe(403); + + const afterRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, { + headers: { 'x-user-token': userJwt }, + }); + expect(afterRes.ok()).toBe(true); + const after = await afterRes.json(); + expect(after.results).toHaveLength(1); + expect(after.results[0].id).toBe(targetId); + } finally { + const deleteRes = await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${address_id}`); + expect(deleteRes.ok()).toBe(true); + } + }); + test('delete a single mail by ID', async ({ request }) => { const { jwt, address } = await createTestAddress(request, 'del-single'); diff --git a/frontend/src/views/user/UserMailBox.vue b/frontend/src/views/user/UserMailBox.vue index 20be53a5..1c0d5317 100644 --- a/frontend/src/views/user/UserMailBox.vue +++ b/frontend/src/views/user/UserMailBox.vue @@ -3,9 +3,11 @@ import { onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n' import { api } from '../../api' +import { useGlobalState } from '../../store' import MailBox from '../../components/MailBox.vue'; const message = useMessage() +const { openSettings } = useGlobalState() const { t } = useI18n({ messages: { @@ -78,7 +80,7 @@ onMounted(() => {
- diff --git a/worker/src/user_api/user_mail_api.ts b/worker/src/user_api/user_mail_api.ts index f0bde6ea..7aeeeefa 100644 --- a/worker/src/user_api/user_mail_api.ts +++ b/worker/src/user_api/user_mail_api.ts @@ -1,6 +1,8 @@ import { Context } from "hono"; +import i18n from "../i18n"; import { handleMailListQuery } from "../common"; import UserBindAddressModule from "./bind_address"; +import { getBooleanValue } from "../utils"; export default { getMails: async (c: Context) => { @@ -26,6 +28,10 @@ export default { ); }, deleteMail: async (c: Context) => { + const msgs = i18n.getMessagesbyContext(c); + if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) { + return c.text(msgs.UserDeleteEmailDisabledMsg, 403) + } const { id } = c.req.param(); const { user_id } = c.get("userPayload"); const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);