feat: add admin mail detail API (#1099)

* feat: add admin mail detail API

* docs: clarify admin mail detail response
This commit is contained in:
Dream Hunter
2026-07-31 15:36:00 +08:00
committed by GitHub
parent 2dcbad40ad
commit 116ddc7324
8 changed files with 127 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@
### Features ### Features
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储(issue #1096
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为 - feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览 - feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:``data:image/``blob:` 与站内相对路径),其余一律阻断;`base``meta``script``link``iframe``object``embed``noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()``image-set()``@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073 - feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:``data:image/``blob:` 与站内相对路径),其余一律阻断;`base``meta``script``link``iframe``object``embed``noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()``image-set()``@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073
+1
View File
@@ -10,6 +10,7 @@
### Features ### Features
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior - feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews - feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073) - feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
+28
View File
@@ -140,6 +140,34 @@ test.describe('Mail Gzip Storage', () => {
} }
}); });
test('gzip-compressed mail is readable through admin detail API', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-detail');
try {
await receiveGzipMail(request, address, {
subject: 'Gzip Admin Detail Test',
text: 'admin compressed content',
});
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const { results } = await listRes.json();
expect(results.length).toBeGreaterThanOrEqual(1);
const mailId = results[0].id;
const detailRes = await request.get(`${WORKER_GZIP_URL}/admin/mails/${mailId}`, {
headers: { 'x-admin-auth': 'e2e-admin-pass' },
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.raw).toContain('Gzip Admin Detail Test');
expect(mail.raw).toContain('admin compressed content');
expect(mail.raw_blob).toBeUndefined();
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => { test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed'); const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
try { try {
+45
View File
@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers'; import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Mail Detail', () => { test.describe('Mail Detail', () => {
test('fetch a single mail by ID', async ({ request }) => { test('fetch a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'detail-get'); const { jwt, address } = await createTestAddress(request, 'detail-get');
@@ -53,3 +55,46 @@ test.describe('Mail Detail', () => {
} }
}); });
}); });
test.describe('Admin Mail Detail', () => {
test('fetch a single mail by ID without a mailbox JWT', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'admin-detail-get');
try {
await seedTestMail(request, address, {
subject: 'Admin Detail Test',
from: 'admin-detail@test.example.com',
text: 'Hello admin detail',
});
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results).toHaveLength(1);
const mailId = results[0].id;
const detailRes = await request.get(`${WORKER_URL}/admin/mails/${mailId}`, {
headers: ADMIN_HEADERS,
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.id).toBe(mailId);
expect(mail.address).toBe(address);
expect(mail.source).toBe('admin-detail@test.example.com');
expect(mail.raw).toContain('Admin Detail Test');
expect(mail.raw_blob).toBeUndefined();
} finally {
await deleteAddress(request, jwt);
}
});
test('fetch non-existent mail returns null', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/admin/mails/99999999`, {
headers: ADMIN_HEADERS,
});
expect(res.ok()).toBe(true);
expect(await res.json()).toBeNull();
});
});
@@ -49,6 +49,27 @@ print(response.json())
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page. **Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Get Mail API
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
The response matches one entry returned by `/admin/mails`: gzip-compressed raw content is decompressed into `raw`, and `raw_blob` is excluded.
```python
import requests
mail_id = 1
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.get(url, headers=headers)
print(response.json())
```
## Admin Delete Mail API ## Admin Delete Mail API
Delete a single mail by mail ID. Delete a single mail by mail ID.
@@ -49,6 +49,27 @@ print(response.json())
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。 **注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
## admin 获取单封邮件 API
无需邮箱 JWT,通过邮件 ID 获取单封邮件,并使用 `x-admin-auth` 认证。
返回结构与 `/admin/mails` 中的单条记录一致:gzip 压缩的原始邮件会解压到 `raw`,响应不包含 `raw_blob`
```python
import requests
mail_id = 1
url = f"https://<你的worker地址>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<你的Admin密码>",
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
}
response = requests.get(url, headers=headers)
print(response.json())
```
## admin 删除邮件 API ## admin 删除邮件 API
通过邮件 ID 删除单封邮件。 通过邮件 ID 删除单封邮件。
+9
View File
@@ -1,5 +1,6 @@
import { Context } from "hono"; import { Context } from "hono";
import { handleMailListQuery } from "../common"; import { handleMailListQuery } from "../common";
import { resolveRawEmailRow } from "../gzip";
export default { export default {
getMails: async (c: Context<HonoCustomType>) => { getMails: async (c: Context<HonoCustomType>) => {
@@ -24,6 +25,14 @@ export default {
[], limit, offset [], limit, offset
); );
}, },
getMail: async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails WHERE id = ?`
).bind(id).first();
if (!result) return c.json(null);
return c.json(await resolveRawEmailRow(result));
},
deleteMail: async (c: Context<HonoCustomType>) => { deleteMail: async (c: Context<HonoCustomType>) => {
const { id } = c.req.param(); const { id } = c.req.param();
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
+1
View File
@@ -33,6 +33,7 @@ api.post('/admin/address/:id/reset_password', address_api.resetPassword)
// mail api // mail api
api.get('/admin/mails', admin_mail_api.getMails) api.get('/admin/mails', admin_mail_api.getMails)
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails) api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
api.get('/admin/mails/:id', admin_mail_api.getMail)
api.delete('/admin/mails/:id', admin_mail_api.deleteMail) api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
// address sender // address sender