feat: v2.1.0 界面与凭据改进 —— 单一 API/MCP Key、邮件 Markdown 正文、/inboxs 域名筛选、暗色主题
- 单一 Key:API 与 MCP 共用一个 wmh_ Key,凭据迁移至 settings 表(0004 删除 api_credentials) - Markdown:入库时 HTML→Markdown(mailhook 同思路),webhook payload 新增 markdown 字段 - /emails 路由改为 /inboxs 并支持域名筛选;设置按钮移入头像下拉 - 主题切换按钮(头像左侧,亮/暗持久化);设置模态仅 全局 Webhook/账号密码 两 tab 且高度限屏 - 列表操作列按钮加 SVG 图标;mailboxes 去 Webhook 列;最近邮件加收件人列
This commit is contained in:
+51
-54
@@ -1,44 +1,42 @@
|
||||
/** API v1 / MCP 测试:Key 隔离、过期邮件 410、MCP 协议与工具 */
|
||||
/** API v1 / MCP 测试:单一 Key(共用)、过期邮件 410、MCP 协议与工具(v2.1.0) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
||||
import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL } from "./helpers.ts";
|
||||
|
||||
/** # 函数
|
||||
* 创建 Key 并返回明文
|
||||
* 创建(轮换)单一 API/MCP Key 并返回明文
|
||||
*/
|
||||
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string, kind: "api" | "mcp") {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind }),
|
||||
});
|
||||
async function createKey(t: Awaited<ReturnType<typeof makeTestEnv>>, cookie: string) {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "POST" });
|
||||
expect(resp.status).toBe(201);
|
||||
return ((await resp.json()) as { key: string }).key;
|
||||
}
|
||||
|
||||
describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("api key 可读资源;mcp key 访问 v1 被 403(kind 隔离)", async () => {
|
||||
test("单一 Key 同时可访问 API 与 MCP(不再区分 kind)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const key = await createKey(t, cookie);
|
||||
|
||||
const ok = await t.app.fetch(
|
||||
const v1 = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const data = (await ok.json()) as { items: { address: string }[] };
|
||||
expect(v1.status).toBe(200);
|
||||
const data = (await v1.json()) as { items: { address: string }[] };
|
||||
expect(data.items.map((m) => m.address)).toContain("*@example.test");
|
||||
|
||||
const bad = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${mcpKey}` },
|
||||
const mcp = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(bad.status).toBe(403);
|
||||
expect(mcp.status).toBe(200);
|
||||
});
|
||||
|
||||
test("无 key / 伪造 key / 已撤销 key 均被拒", async () => {
|
||||
@@ -55,30 +53,37 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
);
|
||||
expect(fake.status).toBe(401);
|
||||
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
// 找到 key id 并撤销
|
||||
const list = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/keys")
|
||||
).json()) as { items: { id: string; revoked_at: string | null }[] };
|
||||
const keyId = list.items.find((k) => !k.revoked_at)!.id;
|
||||
await authedFetch(t, cookie, `http://localhost/api/keys/${keyId}/revoke`, { method: "POST" });
|
||||
const key = await createKey(t, cookie);
|
||||
// 撤销(删除 settings 中的 Key)
|
||||
const del = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
const revoked = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(revoked.status).toBe(401);
|
||||
|
||||
// 重新生成后旧 Key 依旧失效(轮换语义)
|
||||
await createKey(t, cookie);
|
||||
const rotated = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(rotated.status).toBe(401);
|
||||
});
|
||||
|
||||
test("v1 可创建与删除 mailbox", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const create = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mailboxes", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_id: t.domainId, address: "newbox" }),
|
||||
}),
|
||||
t.env,
|
||||
@@ -89,7 +94,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
const del = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/mailboxes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -99,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
test("过期邮件:v1 详情返回 410,列表不出现", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -111,14 +116,14 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
.run();
|
||||
const detail = await t.app.fetch(
|
||||
new Request(`http://localhost/api/v1/emails/${email.id}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
expect(detail.status).toBe(410);
|
||||
const list = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/emails", {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
@@ -128,35 +133,27 @@ describe("Console API v1(Bearer wmh_ Key)", () => {
|
||||
});
|
||||
|
||||
describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, mcpKey: string, body: unknown) {
|
||||
async function mcpCall(t: Awaited<ReturnType<typeof makeTestEnv>>, key: string, body: unknown) {
|
||||
return t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${mcpKey}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
}
|
||||
|
||||
test("api key 不能访问 MCP(权限基于 credential kind)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const apiKey = await createKey(t, cookie, "api");
|
||||
const resp = await mcpCall(t, apiKey, { jsonrpc: "2.0", id: 1, method: "tools/list" });
|
||||
expect(resp.status).toBe(403);
|
||||
});
|
||||
|
||||
test("initialize + tools/list 返回五个工具与 serverInfo", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const init = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||
const key = await createKey(t, cookie);
|
||||
const init = await mcpCall(t, key, { jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||
const initData = (await init.json()) as { result: { serverInfo: { name: string }; protocolVersion: string } };
|
||||
expect(initData.result.serverInfo.name).toBe("wmailhub");
|
||||
expect(initData.result.protocolVersion).toBe("2025-06-18");
|
||||
|
||||
const tools = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
||||
const tools = await mcpCall(t, key, { jsonrpc: "2.0", id: 2, method: "tools/list" });
|
||||
const toolsData = (await tools.json()) as { result: { tools: { name: string }[] } };
|
||||
expect(toolsData.result.tools.map((x) => x.name)).toEqual([
|
||||
"list_mailboxes",
|
||||
@@ -170,7 +167,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
test("tools/call:create_mailbox -> list_mailboxes -> get_email -> delete_mailbox", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const key = await createKey(t, cookie);
|
||||
const email = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL),
|
||||
mailFrom: "a@b.c",
|
||||
@@ -178,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
accountId: t.accountId,
|
||||
});
|
||||
|
||||
const created = await mcpCall(t, mcpKey, {
|
||||
const created = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
@@ -187,7 +184,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
const createdData = (await created.json()) as { result: { content: { text: string }[] } };
|
||||
expect(JSON.parse(createdData.result.content[0].text).address).toBe("mcptest@example.test");
|
||||
|
||||
const list = await mcpCall(t, mcpKey, {
|
||||
const list = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 4,
|
||||
method: "tools/call",
|
||||
@@ -199,7 +196,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
};
|
||||
expect(mailboxes.mailboxes.map((m) => m.address)).toContain("mcptest@example.test");
|
||||
|
||||
const got = await mcpCall(t, mcpKey, {
|
||||
const got = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "tools/call",
|
||||
@@ -210,7 +207,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
expect(gotEmail.email.subject).toBe("Test Mail");
|
||||
|
||||
const target = mailboxes.mailboxes.find((m) => m.address === "mcptest@example.test")!.id;
|
||||
const del = await mcpCall(t, mcpKey, {
|
||||
const del = await mcpCall(t, key, {
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "tools/call",
|
||||
@@ -223,13 +220,13 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => {
|
||||
test("未知方法返回 -32601;GET 返回 405", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const mcpKey = await createKey(t, cookie, "mcp");
|
||||
const bad = await mcpCall(t, mcpKey, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
|
||||
const key = await createKey(t, cookie);
|
||||
const bad = await mcpCall(t, key, { jsonrpc: "2.0", id: 9, method: "no/such/method" });
|
||||
const badData = (await bad.json()) as { error: { code: number } };
|
||||
expect(badData.error.code).toBe(-32601);
|
||||
const get = await t.app.fetch(
|
||||
new Request("http://localhost/api/v1/mcp", {
|
||||
headers: { Authorization: `Bearer ${mcpKey}` },
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
t.env,
|
||||
);
|
||||
|
||||
+1
-2
@@ -48,10 +48,9 @@ describe("用户认证(默认 admin/admin)", () => {
|
||||
test("跨域 Origin 写请求被拒(CSRF)", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/keys", {
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings/api-key", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Origin: "https://evil.example.com" },
|
||||
body: JSON.stringify({ kind: "api" }),
|
||||
});
|
||||
expect(resp.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 一次性 E2E 验证:authorize -> ingest 发送带 HTML 正文的邮件,
|
||||
* 等 webhook 投递后检查 receiver 收到的 payload 是否包含 markdown 字段。
|
||||
*/
|
||||
|
||||
import { signWorkerRequest, generateWmailKey } from "../shared/src/index.ts";
|
||||
|
||||
const CONSOLE = "http://127.0.0.1:18787";
|
||||
|
||||
/** # 函数
|
||||
* 登录拿会话 cookie
|
||||
*/
|
||||
async function login(): Promise<string> {
|
||||
const resp = await fetch(`${CONSOLE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin" }),
|
||||
});
|
||||
const setCookie = resp.headers.get("Set-Cookie") ?? "";
|
||||
return /wmh_session=[^;]+/.exec(setCookie)?.[0] ?? "";
|
||||
}
|
||||
|
||||
const cookie = await login();
|
||||
if (!cookie) throw new Error("login failed");
|
||||
|
||||
/** 取 worker secret(签名用) */
|
||||
const secretResp = await fetch(`${CONSOLE}/api/accounts`, { headers: { Cookie: cookie } });
|
||||
const accounts = (await secretResp.json()) as { items: { id: string; status: string }[] };
|
||||
const account = accounts.items.find((a) => a.status === "active");
|
||||
if (!account) throw new Error("no active account");
|
||||
const wsResp = await fetch(`${CONSOLE}/api/accounts/${account.id}/worker-secret`, { headers: { Cookie: cookie } });
|
||||
const { worker_id, secret } = (await wsResp.json()) as { worker_id: string; secret: string };
|
||||
console.log("worker:", worker_id, "secret:", secret.slice(0, 8) + "…");
|
||||
|
||||
/** 带结构化 HTML 的测试邮件(标题/加粗/链接/列表/表格) */
|
||||
const mime = [
|
||||
"From: E2E <e2e@example.org>",
|
||||
"To: hello@example.test",
|
||||
"Subject: Markdown 全链测试",
|
||||
"Message-ID: <md-e2e-1@example.org>",
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: text/html; charset="utf-8"',
|
||||
"",
|
||||
"<html><body><h2>升级通知</h2><p>您的配额已 <b>用尽</b>,请前往 <a href=\"https://example.com/billing\">账单页</a> 处理。</p>",
|
||||
"<ul><li>方案 A</li><li>方案 B</li></ul>",
|
||||
"<table><tr><th>项目</th><th>用量</th></tr><tr><td>存储</td><td>98%</td></tr></table></body></html>",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
const recipient = "hello@example.test";
|
||||
const sender = "e2e@example.org";
|
||||
|
||||
// Phase 1: authorize
|
||||
const auth = await signWorkerRequest(secret, worker_id, "POST", `${CONSOLE}/internal/email/authorize`,
|
||||
JSON.stringify({ recipient, sender, message_id: "md-e2e-1@example.org" }));
|
||||
const authResp = await fetch(`${CONSOLE}/internal/email/authorize`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipient, sender, message_id: "md-e2e-1@example.org" }),
|
||||
});
|
||||
const decision = (await authResp.json()) as { decision: string };
|
||||
console.log("authorize:", authResp.status, JSON.stringify(decision));
|
||||
if (decision.decision !== "accept") throw new Error("not accepted");
|
||||
|
||||
// Phase 2: ingest(raw MIME)
|
||||
const ingestAuth = await signWorkerRequest(secret, worker_id, "POST", `${CONSOLE}/internal/email/ingest`, mime);
|
||||
const ingestResp = await fetch(`${CONSOLE}/internal/email/ingest`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...ingestAuth,
|
||||
"Content-Type": "message/rfc822",
|
||||
"X-WmailHub-Recipient": recipient,
|
||||
"X-WmailHub-Sender": sender,
|
||||
},
|
||||
body: mime,
|
||||
});
|
||||
const ingestData = (await ingestResp.json()) as { ok: boolean; id: string; webhook: string | null };
|
||||
console.log("ingest:", ingestResp.status, JSON.stringify(ingestData));
|
||||
|
||||
// 触发 cron 投递
|
||||
const cron = await fetch(`${CONSOLE}/api/admin/cron/run`, { method: "POST", headers: { Cookie: cookie } });
|
||||
console.log("cron:", cron.status, JSON.stringify(await cron.json()));
|
||||
|
||||
// 检查 receiver 侧收到的 payload(直接查库里这条邮件的 markdown_body + receiver 日志)
|
||||
const detail = await fetch(`${CONSOLE}/api/emails/${ingestData.id}`, { headers: { Cookie: cookie } });
|
||||
const emailDetail = (await detail.json()) as { email: { markdown_body: string | null; subject: string } };
|
||||
console.log("markdown_body from DB:", JSON.stringify(emailDetail.email.markdown_body));
|
||||
if (!emailDetail.email.markdown_body?.includes("**用尽**")) throw new Error("markdown missing bold");
|
||||
|
||||
// 等 receiver 落日志后抓最后一条校验 markdown 字段
|
||||
await Bun.write("/tmp/last_payload_check.txt", "pending");
|
||||
const tail = await Bun.file("/tmp/receiver.log").text();
|
||||
const lines = tail.trim().split("\n").filter(Boolean);
|
||||
const last = lines[lines.length - 1];
|
||||
const payload = JSON.parse(last) as Record<string, unknown>;
|
||||
console.log("receiver last:", JSON.stringify(payload).slice(0, 300));
|
||||
const md = String(payload.markdown ?? "");
|
||||
if (!md.includes("**用尽**") || !md.includes("98%")) {
|
||||
console.log("FULL MARKDOWN:", md);
|
||||
throw new Error("receiver payload missing markdown");
|
||||
}
|
||||
console.log("\n=== E2E OK: webhook payload 含完整 Markdown ===");
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ Bun.serve({
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
mailbox: payload.mailbox,
|
||||
markdown: payload.markdown,
|
||||
custom_header: req.headers.get("X-Custom"),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/** Markdown 转换与 webhook markdown 字段测试(v2.1.0) */
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { htmlToMarkdown } from "../shared/src/markdown.ts";
|
||||
import { ingestEmail } from "../console/src/services/email_ingest.ts";
|
||||
import { buildWebhookPayload } from "../console/src/services/webhook_delivery.ts";
|
||||
import { loginCookie, makeTestEnv, TEST_MAIL, authedFetch } from "./helpers.ts";
|
||||
|
||||
/** # 成员变量: 纯文本邮件样例(无 HTML part) **/
|
||||
const TEST_MAIL_PLAIN = `From: Bob <bob@example.org>\r
|
||||
To: hello@example.test\r
|
||||
Subject: Plain Mail\r
|
||||
Content-Type: text/plain\r
|
||||
\r
|
||||
plain body\r
|
||||
`;
|
||||
|
||||
/** # 成员变量: HTML 邮件样例(text/html part 带 p/b 结构) **/
|
||||
const TEST_MAIL_HTML = `From: Carl <carl@example.org>\r
|
||||
To: hello@example.test\r
|
||||
Subject: Html Mail\r
|
||||
MIME-Version: 1.0\r
|
||||
Content-Type: text/html\r
|
||||
\r
|
||||
<html><body><p>Hello <b>world</b> and <a href="https://example.com">link</a></p></body></html>\r
|
||||
`;
|
||||
|
||||
describe("htmlToMarkdown(mailhook 同思路)", () => {
|
||||
test("标题/加粗/链接/行内代码", () => {
|
||||
expect(htmlToMarkdown("<h2>Hello</h2><p>World <b>bold</b> <a href='https://x.com'>link</a> <code>cfg</code></p>")).toBe(
|
||||
"## Hello\n\nWorld **bold** [link](https://x.com) `cfg`",
|
||||
);
|
||||
});
|
||||
|
||||
test("列表与嵌套列表", () => {
|
||||
expect(htmlToMarkdown("<ul><li>one</li><li>two<ol><li>nested</li></ol></li></ul>")).toBe(
|
||||
"- one\n\n- two\n\n 1. nested",
|
||||
);
|
||||
});
|
||||
|
||||
test("表格转管道表(| 转义)", () => {
|
||||
expect(htmlToMarkdown("<table><tr><th>A</th></tr><tr><td>1|2</td></tr></table>")).toBe(
|
||||
"| A |\n| --- |\n| 1\\|2 |",
|
||||
);
|
||||
});
|
||||
|
||||
test("pre 代码块保留换行;blockquote 引用;hr 分隔线", () => {
|
||||
expect(htmlToMarkdown("<pre>line1\nline2</pre>")).toBe("```\nline1\nline2\n```");
|
||||
expect(htmlToMarkdown("<blockquote>quoted</blockquote>")).toBe("> quoted");
|
||||
expect(htmlToMarkdown("<hr/>")).toBe("---");
|
||||
});
|
||||
|
||||
test("script/style 丢弃;空内容返回空串", () => {
|
||||
expect(htmlToMarkdown("<script>alert(1)</script><style>.x{}</style>")).toBe("");
|
||||
expect(htmlToMarkdown("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("邮件入库 markdown_body 与 webhook markdown 字段", () => {
|
||||
test("HTML 邮件入库时存 Markdown;纯文本邮件 markdown=纯文本", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const html = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL_HTML),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const row = await t.env.DB.prepare("SELECT markdown_body, text_body, html_body FROM emails WHERE id = ?")
|
||||
.bind(html.id)
|
||||
.first<{ markdown_body: string | null; text_body: string | null; html_body: string | null }>();
|
||||
expect(row?.html_body).toBeTruthy();
|
||||
expect(row?.markdown_body).toContain("**");
|
||||
expect(row?.markdown_body?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const plain = await ingestEmail(t.env, {
|
||||
raw: new TextEncoder().encode(TEST_MAIL_PLAIN),
|
||||
mailFrom: "a@b.c",
|
||||
rcptTo: "hello@example.test",
|
||||
accountId: t.accountId,
|
||||
});
|
||||
const row2 = await t.env.DB.prepare("SELECT markdown_body FROM emails WHERE id = ?")
|
||||
.bind(plain.id)
|
||||
.first<{ markdown_body: string | null }>();
|
||||
expect(row2?.markdown_body).toBeTruthy();
|
||||
});
|
||||
|
||||
test("webhook payload 含 markdown 字段(优先存量 markdown_body)", () => {
|
||||
const payload = buildWebhookPayload({
|
||||
id: "msg_x",
|
||||
mailboxAddress: "hello@example.test",
|
||||
fromAddress: "a@b.c",
|
||||
toAddresses: '["hello@example.test"]',
|
||||
subject: "s",
|
||||
textBody: "plain body",
|
||||
htmlBody: "<p>hello <b>world</b></p>",
|
||||
markdownBody: null,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(payload.markdown).toBe("hello **world**");
|
||||
|
||||
const payload2 = buildWebhookPayload({
|
||||
id: "msg_y",
|
||||
mailboxAddress: null,
|
||||
fromAddress: null,
|
||||
toAddresses: "[]",
|
||||
subject: null,
|
||||
textBody: null,
|
||||
htmlBody: null,
|
||||
markdownBody: "stored md",
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
expect(payload2.markdown).toBe("stored md");
|
||||
});
|
||||
|
||||
test("设置接口返回 api_key 元信息;生成接口明文 32 位 wmh_", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
const before = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
api_key: { has_key: boolean; key_last4: string };
|
||||
};
|
||||
expect(before.api_key.has_key).toBe(false);
|
||||
|
||||
const created = (await (
|
||||
await authedFetch(t, cookie, "http://localhost/api/settings/api-key", { method: "POST" })
|
||||
).json()) as { key: string };
|
||||
expect(created.key.startsWith("wmh_")).toBe(true);
|
||||
expect(created.key.length).toBe(32);
|
||||
|
||||
const after = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
api_key: { has_key: boolean; key_last4: string };
|
||||
};
|
||||
expect(after.api_key.has_key).toBe(true);
|
||||
expect(created.key.endsWith(after.api_key.key_last4)).toBe(true);
|
||||
});
|
||||
});
|
||||
+19
-11
@@ -42,7 +42,7 @@ await page.click("button[type=submit]");
|
||||
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
|
||||
await page.waitForSelector("text=概览", { timeout: 8000 });
|
||||
check("登录成功进入概览页", true);
|
||||
// 品牌区版本号 tag(v1.1.0,来源 package.json)
|
||||
// 品牌区版本号 tag(v2.1.0,来源 package.json)
|
||||
const versionTag = await page.locator(".brand-version").textContent();
|
||||
check("LOGO 版本号 tag 显示", Boolean(versionTag && /^v\d+\.\d+\.\d+$/.test(versionTag.trim())), versionTag?.trim());
|
||||
await page.waitForTimeout(600);
|
||||
@@ -53,7 +53,7 @@ const pages = [
|
||||
{ path: "/accounts", expect: "账号", shot: "03-accounts.png" },
|
||||
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
|
||||
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
|
||||
{ path: "/emails", expect: "邮件", shot: "06-emails.png" },
|
||||
{ path: "/inboxs", expect: "收件箱", shot: "06-inboxs.png" },
|
||||
];
|
||||
for (const p of pages) {
|
||||
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
|
||||
@@ -76,7 +76,7 @@ await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// 5. 邮件详情(点击第一行)
|
||||
await page.goto(`${BASE}/emails`, { waitUntil: "domcontentloaded" });
|
||||
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
|
||||
await page.locator(".ant-table-row").first().click();
|
||||
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
|
||||
@@ -86,18 +86,26 @@ await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// 6. 设置模态窗口:基本设置 + 生成 MCP Key
|
||||
await page.locator("button[aria-label='设置']").click();
|
||||
// 头像下拉里打开设置(设置按钮已移入下拉菜单)
|
||||
await page.locator(".ant-dropdown-trigger, .app-header .ant-dropdown-trigger").first().hover();
|
||||
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
|
||||
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
|
||||
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
|
||||
check("设置模态窗口打开(全局 Webhook / Ingress / 密码)", true);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-general.png` });
|
||||
check("设置模态窗口经头像下拉打开(仅 全局 Webhook / 账号密码 两 tab)", true);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
|
||||
|
||||
await page.click(".ant-tabs-tab:has-text('MCP Keys')");
|
||||
await page.waitForSelector("button:has-text('生成 MCP Key')", { timeout: 8000 });
|
||||
await page.click("button:has-text('生成 MCP Key')");
|
||||
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
|
||||
const genBtn = page.getByRole("button", { name: /生成/ });
|
||||
await genBtn.waitFor({ state: "visible", timeout: 8000 });
|
||||
await genBtn.click();
|
||||
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
|
||||
const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent();
|
||||
check("生成 MCP Key 弹窗(wmh_ 32 位)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/10-mcp-key-created.png` });
|
||||
check("生成 API/MCP Key 弹窗(wmh_ 32 位,单一 Key)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/10-api-key-created.png` });
|
||||
|
||||
// 主题切换按钮存在(头像左侧)
|
||||
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
|
||||
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
|
||||
|
||||
await browser.close();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user