+
+
+
+
+ 最多保留 10000 条,超出自动删最旧
+ } onClick={() => void load()} />
+
+
+
+ `共 ${t} 条`,
+ onChange: (p, ps) => {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ columns={[
+ { title: "时间", dataIndex: "created_at", width: 170, render: (v: string) => new Date(v).toLocaleString() },
+ {
+ title: "操作",
+ dataIndex: "action",
+ width: 190,
+ render: (v: string) => ACTION_LABEL[v] ?? v,
+ },
+ {
+ title: "类型",
+ dataIndex: "target_type",
+ width: 100,
+ render: (v: string) => {
+ const m = TYPE_META[v];
+ return m ? {m.label} : {v};
+ },
+ },
+ {
+ title: "对象",
+ dataIndex: "target_name",
+ ellipsis: true,
+ render: (v: string | null, row: OperationLogRow) =>
+ v ? (
+
+ {v}
+
+ ) : (
+ -
+ ),
+ },
+ {
+ title: "详情",
+ dataIndex: "detail",
+ ellipsis: true,
+ render: (v: string | null) =>
+ v ? {detailText(v)} : -,
+ },
+ {
+ title: "操作账号",
+ dataIndex: "operator",
+ width: 120,
+ render: (v: string) => (v === "system" ? system : v || "-"),
+ },
+ ]}
+ />
+
+
+ );
+}
diff --git a/console/frontend/src/pages/Mailboxes.tsx b/console/frontend/src/pages/Mailboxes.tsx
index c3eba1b..b1ab40d 100644
--- a/console/frontend/src/pages/Mailboxes.tsx
+++ b/console/frontend/src/pages/Mailboxes.tsx
@@ -3,8 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App, Tooltip } from "antd";
import {
ApiOutlined,
- CheckOutlined,
- CopyOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
@@ -21,6 +19,7 @@ import {
type DomainRow,
type MailboxRow,
} from "../api.ts";
+import { CopyText } from "../components/CopyText.tsx";
import { WebhookForm } from "../components/WebhookForm.tsx";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
import { batchDeleteMailboxes } from "../api.ts";
@@ -49,8 +48,6 @@ export function MailboxesPage() {
const [deleting, setDeleting] = useState(false);
/** 正在拉取随机用户名的 loading **/
const [fetchingName, setFetchingName] = useState(false);
- /** 刚复制完的邮箱 id(用于播放复制成功的动画) **/
- const [copiedId, setCopiedId] = useState(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -97,18 +94,6 @@ export function MailboxesPage() {
}
};
- /** 复制邮箱地址:成功时按钮播放一下动画并短暂变成对勾 */
- const copyAddress = async (row: MailboxRow) => {
- try {
- await navigator.clipboard.writeText(row.address);
- setCopiedId(row.id);
- setTimeout(() => setCopiedId((cur) => (cur === row.id ? null : cur)), 1000);
- } catch {
- // 剪贴板不可用(非 https / 权限被拒)时退回选中,让用户自己复制
- message.warning("浏览器拒绝了剪贴板访问,请手动复制");
- }
- };
-
const filtered = useMemo(
() =>
items.filter((row) =>
@@ -175,19 +160,7 @@ export function MailboxesPage() {
title: "地址",
dataIndex: "address",
render: (v: string, row: MailboxRow) => (
-
- {v}
- {row.is_catch_all ? catch-all : null}
-
- : }
- onClick={() => void copyAddress(row)}
- />
-
-
+ catch-all : null} />
),
},
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
diff --git a/console/frontend/src/styles.css b/console/frontend/src/styles.css
index 5572e0c..32978d1 100644
--- a/console/frontend/src/styles.css
+++ b/console/frontend/src/styles.css
@@ -217,3 +217,13 @@ body,
70% { transform: scale(0.92); }
100% { transform: scale(1); }
}
+
+/* 保留按钮(收件箱):无边框图钉,悬停变绿 */
+.pin-btn {
+ color: var(--wmh-muted, #8c8c8c);
+}
+
+.pin-btn:hover {
+ color: #52c41a !important;
+ background: rgba(82, 196, 26, 0.10);
+}
diff --git a/console/src/routes/oauth.ts b/console/src/routes/oauth.ts
index 43f32e3..38d2937 100644
--- a/console/src/routes/oauth.ts
+++ b/console/src/routes/oauth.ts
@@ -29,6 +29,7 @@ import {
preflightOAuth,
} from "../services/cfapi.ts";
import { provisionAccount, createAccount } from "../services/provision.ts";
+import { logOperation } from "../services/logs.ts";
import { saveConnection } from "../db.ts";
import { nowIso } from "../util.ts";
@@ -128,6 +129,18 @@ export function oauthRoutes(): App {
expiresIn: token.expiresIn,
});
const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken });
+ // 操作日志:回调不带会话 cookie(SameSite),操作账号记 system 并标注来源
+ const acct = await c.env.DB.prepare("SELECT name FROM cloudflare_accounts WHERE id = ?")
+ .bind(verdict.accountId)
+ .first<{ name: string }>();
+ await logOperation(c.env, {
+ action: "account.connect",
+ targetType: "account",
+ targetId: verdict.accountId,
+ targetName: acct?.name ?? null,
+ detail: { provider: "cloudflare-oauth", ok: result.ok, error: result.error ?? null },
+ operator: "system",
+ });
const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&ok=${result.ok ? 1 : 0}`;
return c.redirect(target, 302);
} catch (err) {
diff --git a/console/src/routes/resources.ts b/console/src/routes/resources.ts
index a46a089..29f5153 100644
--- a/console/src/routes/resources.ts
+++ b/console/src/routes/resources.ts
@@ -9,6 +9,8 @@
* - 批量操作(POST .../batch-delete):四个资源列表的复选框批量删除
* 账号=软删(撤销 Worker 凭据并标记 revoked);域名/邮箱=级联删除其下邮件;
* 邮件=连同 R2 原文与附件一起删除
+ * - POST /emails/retain 保留邮件(不再过期);GET /logs 操作日志查询(v2.5.0)
+ * - 全部写操作写 operation_logs(操作账号 / 操作域名 / 操作邮箱 / Webhook 记录)
*/
import { Hono } from "hono";
@@ -16,6 +18,7 @@ import type { Env, AppVars } from "../env.ts";
import { requireSession } from "../middleware.ts";
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount } from "../services/provision.ts";
import { getCFApi } from "../services/cfapi.ts";
+import { logOperation, operatorName } from "../services/logs.ts";
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
import { checkWebhookUrl, ids } from "../../../shared/src/index.ts";
import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts";
@@ -34,6 +37,7 @@ export function resourceRoutes(): App {
installDomainRoutes(app);
installMailboxRoutes(app);
installEmailRoutes(app);
+ installLogRoutes(app);
return app;
}
@@ -116,13 +120,13 @@ async function deleteEmailsByMailbox(env: Env, mailboxId: string): Promise {
- const row = await env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
+async function revokeAccount(env: Env, id: string): Promise<{ deleted: boolean; name: string | null }> {
+ const row = await env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
.bind(id)
- .first<{ id: string }>();
- if (!row) return false;
+ .first<{ id: string; name: string }>();
+ if (!row) return { deleted: false, name: null };
await env.DB.prepare(
"UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'",
)
@@ -131,7 +135,7 @@ async function revokeAccount(env: Env, id: string): Promise {
await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?")
.bind(nowIso(), id)
.run();
- return true;
+ return { deleted: true, name: row.name };
}
/** # 函数
@@ -180,14 +184,22 @@ function installAccountRoutes(app: App) {
app.patch("/accounts/:id", async (c) => {
const id = c.req.param("id");
const body = await readJson<{ name?: string }>(c.req.raw);
- const account = await c.env.DB.prepare("SELECT id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
+ const account = await c.env.DB.prepare("SELECT id, name FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'")
.bind(id)
- .first();
+ .first<{ id: string; name: string }>();
if (!account) return c.json({ error: "not found" }, 404);
if (body.name !== undefined) {
const name = body.name.trim();
if (!name) return c.json({ error: "name required" }, 400);
await c.env.DB.prepare("UPDATE cloudflare_accounts SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run();
+ await logOperation(c.env, {
+ action: "account.update",
+ targetType: "account",
+ targetId: id,
+ targetName: name,
+ detail: { old_name: account.name },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
}
return c.json({ ok: true });
});
@@ -195,7 +207,16 @@ function installAccountRoutes(app: App) {
/** 删除账号(软删:撤销 Worker credential + 标记 revoked) */
app.delete("/accounts/:id", async (c) => {
const id = c.req.param("id");
- await revokeAccount(c.env, id);
+ const r = await revokeAccount(c.env, id);
+ if (r.deleted) {
+ await logOperation(c.env, {
+ action: "account.delete",
+ targetType: "account",
+ targetId: id,
+ targetName: r.name,
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
@@ -203,10 +224,25 @@ function installAccountRoutes(app: App) {
app.post("/accounts/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
+ const deletedNames: string[] = [];
let deleted = 0;
for (const id of ids) {
- if (await revokeAccount(c.env, id)) deleted++;
- else failed.push({ id, reason: "账号不存在或已删除" });
+ const r = await revokeAccount(c.env, id);
+ if (r.deleted) {
+ deleted++;
+ if (r.name) deletedNames.push(r.name);
+ } else {
+ failed.push({ id, reason: "账号不存在或已删除" });
+ }
+ }
+ if (deleted > 0) {
+ await logOperation(c.env, {
+ action: "account.batch_delete",
+ targetType: "account",
+ targetName: deletedNames.join("、"),
+ detail: { deleted, ids },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
}
return c.json({ ok: true, deleted, failed });
});
@@ -216,10 +252,10 @@ function installAccountRoutes(app: App) {
app.post("/accounts/:id/rotate", async (c) => {
const id = c.req.param("id");
const account = await c.env.DB.prepare(
- "SELECT id, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
+ "SELECT id, name, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
)
.bind(id)
- .first<{ id: string; worker_id: string | null }>();
+ .first<{ id: string; name: string; worker_id: string | null }>();
if (!account) return c.json({ error: "not found" }, 404);
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
@@ -259,6 +295,14 @@ function installAccountRoutes(app: App) {
const r = await setupDomainEmail(c.env, d.id);
if (r.ok) redeployed.push(d.name);
}
+ await logOperation(c.env, {
+ action: "account.rotate",
+ targetType: "account",
+ targetId: id,
+ targetName: account.name ?? null,
+ detail: { credential_id: credentialId, worker_id: account.worker_id, redeployed },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: true, worker_id: account.worker_id, credential_id: credentialId, secret, redeployed });
});
/** 本地/运维用途:查看账号 Worker credential(会话保护;真实部署由 provisioning 自动注入 Worker) */
@@ -325,6 +369,14 @@ function installAccountRoutes(app: App) {
accessToken: token,
cfAccountId: discovered.account.id,
});
+ await logOperation(c.env, {
+ action: "account.create",
+ targetType: "account",
+ targetId: created.accountId,
+ targetName: name,
+ detail: { provider: "cloudflare-token", cloudflare_account_id: discovered.account.id, ok: result.ok, error: result.error },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: result.ok, account_id: created.accountId, error: result.error }, result.ok ? 200 : 400);
});
}
@@ -355,34 +407,77 @@ function installDomainRoutes(app: App) {
/** 为域名配置 Email Workers(部署 Worker + Email Routing + catch-all) */
app.post("/domains/:id/setup", async (c) => {
+ const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?")
+ .bind(c.req.param("id"))
+ .first<{ id: string; name: string }>();
const result = await setupDomainEmail(c.env, c.req.param("id"));
+ if (domain) {
+ await logOperation(c.env, {
+ action: "domain.setup",
+ targetType: "domain",
+ targetId: domain.id,
+ targetName: domain.name,
+ detail: { ok: result.ok, error: result.error ?? null },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json(result, result.ok ? 200 : 400);
});
/** 保存域名级 Webhook(url 为空表示清除) */
app.put("/domains/:id/webhook", async (c) => {
const id = c.req.param("id");
- const domain = await c.env.DB.prepare("SELECT id FROM domains WHERE id = ?").bind(id).first();
+ const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
if (!domain) return c.json({ error: "not found" }, 404);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "domains", id, parsed);
+ await logOperation(c.env, {
+ action: parsed.url ? "webhook.save" : "webhook.clear",
+ targetType: "webhook",
+ targetId: id,
+ targetName: parsed.url || domain.name,
+ detail: { scope: "domain", domain: domain.name },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: true });
});
/** 清除域名级 Webhook */
app.delete("/domains/:id/webhook", async (c) => {
- await saveScopedWebhook(c.env, "domains", c.req.param("id"), { url: "" });
+ const id = c.req.param("id");
+ const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
+ await saveScopedWebhook(c.env, "domains", id, { url: "" });
+ if (domain) {
+ await logOperation(c.env, {
+ action: "webhook.clear",
+ targetType: "webhook",
+ targetId: id,
+ targetName: domain.name,
+ detail: { scope: "domain", domain: domain.name },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
app.delete("/domains/:id", async (c) => {
const id = c.req.param("id");
+ const domain = await c.env.DB.prepare("SELECT id, name FROM domains WHERE id = ?").bind(id).first<{ id: string; name: string }>();
const used = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM mailboxes WHERE domain_id = ?")
.bind(id)
.first<{ n: number }>();
if ((used?.n ?? 0) > 0) return c.json({ error: "domain has mailboxes" }, 409);
await c.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(id).run();
+ if (domain) {
+ await logOperation(c.env, {
+ action: "domain.delete",
+ targetType: "domain",
+ targetId: id,
+ targetName: domain.name,
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
@@ -392,10 +487,14 @@ function installDomainRoutes(app: App) {
app.post("/domains/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
+ const deletedNames: string[] = [];
let deleted = 0;
let mailboxes = 0;
let emails = 0;
for (const id of ids) {
+ const name = (
+ await c.env.DB.prepare("SELECT name FROM domains WHERE id = ?").bind(id).first<{ name: string }>()
+ )?.name ?? null;
const boxes = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE domain_id = ?")
.bind(id)
.all<{ id: string }>();
@@ -408,10 +507,20 @@ function installDomainRoutes(app: App) {
if (changes > 0) {
deleted++;
mailboxes += boxes.results.length;
+ if (name) deletedNames.push(name);
} else {
failed.push({ id, reason: "域名不存在" });
}
}
+ if (deleted > 0) {
+ await logOperation(c.env, {
+ action: "domain.batch_delete",
+ targetType: "domain",
+ targetName: deletedNames.join("、"),
+ detail: { deleted, mailboxes, emails, ids },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true, deleted, mailboxes, emails, failed });
});
}
@@ -459,6 +568,14 @@ function installMailboxRoutes(app: App) {
isCatchAll: Boolean(body.catch_all),
retentionSeconds: body.retention_seconds ? Number(body.retention_seconds) : undefined,
});
+ await logOperation(c.env, {
+ action: "mailbox.create",
+ targetType: "mailbox",
+ targetId: id,
+ targetName: address,
+ detail: { domain: domain.name, catch_all: Boolean(body.catch_all) },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: true, id, address }, 201);
});
@@ -468,7 +585,9 @@ function installMailboxRoutes(app: App) {
if (body.status && !["active", "disabled"].includes(body.status)) {
return c.json({ error: "invalid status" }, 400);
}
- const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
+ const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
+ .bind(id)
+ .first<{ id: string; address: string }>();
if (!mailbox) return c.json({ error: "not found" }, 404);
if (body.status) {
await c.env.DB.prepare("UPDATE mailboxes SET status = ?, updated_at = ? WHERE id = ?")
@@ -480,29 +599,75 @@ function installMailboxRoutes(app: App) {
.bind(body.retention_seconds ? Number(body.retention_seconds) : null, nowIso(), id)
.run();
}
+ if (body.status || body.retention_seconds !== undefined) {
+ await logOperation(c.env, {
+ action: "mailbox.update",
+ targetType: "mailbox",
+ targetId: id,
+ targetName: mailbox.address,
+ detail: { status: body.status, retention_seconds: body.retention_seconds },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
/** 保存邮箱级 Webhook(url 为空表示清除) */
app.put("/mailboxes/:id/webhook", async (c) => {
const id = c.req.param("id");
- const mailbox = await c.env.DB.prepare("SELECT id FROM mailboxes WHERE id = ?").bind(id).first();
+ const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
+ .bind(id)
+ .first<{ id: string; address: string }>();
if (!mailbox) return c.json({ error: "not found" }, 404);
const parsed = await parseWebhookBody(c);
if ("error" in parsed) return c.json({ error: parsed.error }, 400);
await saveScopedWebhook(c.env, "mailboxes", id, parsed);
+ await logOperation(c.env, {
+ action: parsed.url ? "webhook.save" : "webhook.clear",
+ targetType: "webhook",
+ targetId: id,
+ targetName: parsed.url || mailbox.address,
+ detail: { scope: "mailbox", mailbox: mailbox.address },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: true });
});
/** 清除邮箱级 Webhook */
app.delete("/mailboxes/:id/webhook", async (c) => {
- await saveScopedWebhook(c.env, "mailboxes", c.req.param("id"), { url: "" });
+ const id = c.req.param("id");
+ const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
+ .bind(id)
+ .first<{ id: string; address: string }>();
+ await saveScopedWebhook(c.env, "mailboxes", id, { url: "" });
+ if (mailbox) {
+ await logOperation(c.env, {
+ action: "webhook.clear",
+ targetType: "webhook",
+ targetId: id,
+ targetName: mailbox.address,
+ detail: { scope: "mailbox", mailbox: mailbox.address },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
app.delete("/mailboxes/:id", async (c) => {
const id = c.req.param("id");
+ const mailbox = await c.env.DB.prepare("SELECT id, address FROM mailboxes WHERE id = ?")
+ .bind(id)
+ .first<{ id: string; address: string }>();
await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
+ if (mailbox) {
+ await logOperation(c.env, {
+ action: "mailbox.delete",
+ targetType: "mailbox",
+ targetId: id,
+ targetName: mailbox.address,
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true });
});
@@ -510,14 +675,29 @@ function installMailboxRoutes(app: App) {
app.post("/mailboxes/batch-delete", async (c) => {
const ids = await readIds(c);
const failed: { id: string; reason: string }[] = [];
+ const deletedNames: string[] = [];
let deleted = 0;
let emails = 0;
for (const id of ids) {
+ const address = (
+ await c.env.DB.prepare("SELECT address FROM mailboxes WHERE id = ?").bind(id).first<{ address: string }>()
+ )?.address ?? null;
emails += await deleteEmailsByMailbox(c.env, id);
const res = await c.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(id).run();
const changes = (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
- if (changes > 0) deleted++;
- else failed.push({ id, reason: "邮箱不存在" });
+ if (changes > 0) {
+ deleted++;
+ if (address) deletedNames.push(address);
+ } else failed.push({ id, reason: "邮箱不存在" });
+ }
+ if (deleted > 0) {
+ await logOperation(c.env, {
+ action: "mailbox.batch_delete",
+ targetType: "mailbox",
+ targetName: deletedNames.join("、"),
+ detail: { deleted, emails, ids },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
}
return c.json({ ok: true, deleted, emails, failed });
});
@@ -550,7 +730,7 @@ function installEmailRoutes(app: App) {
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
const rows = await c.env.DB.prepare(
`SELECT e.id, e.mailbox_id, e.message_id, e.from_address, e.to_addresses, e.subject, e.size,
- e.received_at, e.expires_at, m.address AS mailbox_address,
+ e.received_at, e.expires_at, e.retained, m.address AS mailbox_address,
(SELECT COUNT(*) FROM email_attachments a WHERE a.email_id = e.id) AS attachment_count
FROM emails e LEFT JOIN mailboxes m ON m.id = e.mailbox_id
${where} ORDER BY e.received_at DESC LIMIT ? OFFSET ?`,
@@ -615,7 +795,17 @@ function installEmailRoutes(app: App) {
app.delete("/emails/:id", async (c) => {
const id = c.req.param("id");
+ const target = await c.env.DB.prepare("SELECT subject, from_address FROM emails WHERE id = ?")
+ .bind(id)
+ .first<{ subject: string | null; from_address: string | null }>();
if (!(await deleteEmailById(c.env, id))) return c.json({ error: "not found" }, 404);
+ await logOperation(c.env, {
+ action: "email.delete",
+ targetType: "email",
+ targetId: id,
+ targetName: target?.subject ?? target?.from_address ?? null,
+ operator: await operatorName(c.env, c.get("userId")),
+ });
return c.json({ ok: true });
});
@@ -628,6 +818,72 @@ function installEmailRoutes(app: App) {
if (await deleteEmailById(c.env, id)) deleted++;
else failed.push({ id, reason: "邮件不存在或已过期清理" });
}
+ if (deleted > 0) {
+ await logOperation(c.env, {
+ action: "email.batch_delete",
+ targetType: "email",
+ detail: { deleted, ids },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
return c.json({ ok: true, deleted, failed });
});
+
+ /** 保留邮件(单条/批量):retained=1 后 Cron TTL 清理跳过,永不过期 */
+ app.post("/emails/retain", async (c) => {
+ const ids = await readIds(c);
+ let retained = 0;
+ for (const id of ids) {
+ const res = await c.env.DB.prepare("UPDATE emails SET retained = 1 WHERE id = ? AND retained = 0")
+ .bind(id)
+ .run();
+ retained += (res as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0;
+ }
+ if (retained > 0) {
+ await logOperation(c.env, {
+ action: "email.retain",
+ targetType: "email",
+ detail: { retained, ids },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
+ }
+ return c.json({ ok: true, retained });
+ });
+}
+
+/** # 函数
+ * 操作日志查询(日志页):按对象类型筛选 + 分页,时间倒序
+ */
+function installLogRoutes(app: App) {
+ app.get("/logs", async (c) => {
+ const url = new URL(c.req.url);
+ const { limit, offset } = pageParams(url);
+ const targetType = url.searchParams.get("target_type");
+ const action = url.searchParams.get("action");
+ const q = url.searchParams.get("q");
+ const conds: string[] = [];
+ const binds: unknown[] = [];
+ if (targetType) {
+ conds.push("target_type = ?");
+ binds.push(targetType);
+ }
+ if (action) {
+ conds.push("action = ?");
+ binds.push(action);
+ }
+ if (q) {
+ conds.push("(target_name LIKE ? OR operator LIKE ?)");
+ binds.push(`%${q}%`, `%${q}%`);
+ }
+ const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
+ const rows = await c.env.DB.prepare(
+ `SELECT id, action, target_type, target_id, target_name, detail, operator, created_at
+ FROM operation_logs ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
+ );
+ const res = await (binds.length ? rows.bind(...binds, limit, offset) : rows.bind(limit, offset)).all();
+ const totalRows = binds.length
+ ? await c.env.DB.prepare(`SELECT COUNT(*) AS n FROM operation_logs ${where}`).bind(...binds).first<{ n: number }>()
+ : await c.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
+ return c.json({ items: res.results, total: totalRows?.n ?? 0 });
+ });
}
diff --git a/console/src/routes/settings.ts b/console/src/routes/settings.ts
index e90cb77..6dba808 100644
--- a/console/src/routes/settings.ts
+++ b/console/src/routes/settings.ts
@@ -29,11 +29,8 @@ import {
oauthRedirectUri,
saveOAuthClient,
} from "../services/cfapi.ts";
-import {
- getGlobalWebhookKey,
- normalizeWebhookHeaders,
- saveGlobalWebhook,
-} from "../services/webhook_delivery.ts";
+import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts";
+import { logOperation, operatorName } from "../services/logs.ts";
import { nowIso, readJson } from "../util.ts";
type App = Hono<{ Bindings: Env; Variables: AppVars }>;
@@ -112,6 +109,13 @@ export function settingsRoutes(): App {
headers,
key: rawKey !== undefined && rawKey.trim() === "" ? "" : rawKey,
});
+ await logOperation(c.env, {
+ action: url ? "webhook.save" : "webhook.clear",
+ targetType: "webhook",
+ targetName: url || "全局 Webhook",
+ detail: { scope: "global" },
+ operator: await operatorName(c.env, c.get("userId")),
+ });
}
if (body.cf_oauth) {
diff --git a/console/src/services/cleanup.ts b/console/src/services/cleanup.ts
index 871babd..3b797c8 100644
--- a/console/src/services/cleanup.ts
+++ b/console/src/services/cleanup.ts
@@ -4,7 +4,7 @@
* R2 失败则保留 DB 记录留待下次 Cron 重试(幂等)。
* - cleanupOrphans:附件行存在但邮件已删 -> 删 R2 对象 + 附件行;
* delivery 指向已删邮件 -> 删 delivery 行(数据最小化)。
- * - housekeeping:过期 nonce、过期 OAuth state 清理。
+ * - housekeeping:过期 nonce、过期 OAuth state 清理;操作日志超限裁剪(最多 10000 条,删最旧)。
*/
import type { Env } from "../env.ts";
@@ -49,11 +49,12 @@ export async function runCron(
/** # 函数
* 删除过期邮件(R2 先行,全部成功才删 DB)
+ * 「保留」(retained=1)的邮件跳过:用户点名要留的邮件不受 TTL 约束
*/
export async function cleanupExpiredEmails(env: Env, stats: CleanupStats): Promise {
const now = nowIso();
const expired = await env.DB.prepare(
- "SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? LIMIT 100",
+ "SELECT id, raw_storage_key FROM emails WHERE expires_at <= ? AND retained = 0 LIMIT 100",
)
.bind(now)
.all<{ id: string; raw_storage_key: string }>();
@@ -105,12 +106,20 @@ export async function cleanupOrphans(env: Env, stats: CleanupStats): Promise {
const now = nowIso();
const n1 = await env.DB.prepare("DELETE FROM nonces WHERE expires_at <= ?").bind(now).run();
const n2 = await env.DB.prepare("DELETE FROM oauth_states WHERE expires_at <= ?").bind(now).run();
- stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0);
+ // 操作日志最多保留 OPERATION_LOG_MAX 条:按时间倒序留新删旧
+ const n3 = await env.DB.prepare(
+ `DELETE FROM operation_logs WHERE id NOT IN
+ (SELECT id FROM operation_logs ORDER BY created_at DESC, id DESC LIMIT ${OPERATION_LOG_MAX})`,
+ ).run();
+ stats.housekeeping += (n1.meta?.changes ?? 0) + (n2.meta?.changes ?? 0) + (n3.meta?.changes ?? 0);
}
diff --git a/console/src/services/logs.ts b/console/src/services/logs.ts
new file mode 100644
index 0000000..d3cc862
--- /dev/null
+++ b/console/src/services/logs.ts
@@ -0,0 +1,69 @@
+/**
+ * 操作日志(docs:v2.5.0):Console 内所有人工/系统操作的统一落点。
+ * - 写入方:资源路由(账号/域名/邮箱/Webhook 配置)、OAuth 接入、Cron 投递结局。
+ * - detail 只放非敏感元信息(id、url、数量、错误消息);secret / token / key 永不入该表。
+ * - logOperation 自身失败只记 console,绝不打断业务操作(日志是旁路,不能反噬主流程)。
+ */
+
+import type { Env } from "../env.ts";
+import { ids } from "../../../shared/src/index.ts";
+import { nowIso } from "../util.ts";
+
+/** # 成员变量: 操作对象类型 **/
+export type OpTargetType = "account" | "domain" | "mailbox" | "email" | "webhook";
+
+/** # 成员变量: 写一条操作日志的参数 **/
+export interface OperationLogInput {
+ /** 动作名,如 account.create / domain.setup / webhook.deliver **/
+ action: string;
+ /** 操作对象类型 **/
+ targetType: OpTargetType;
+ /** 被操作资源 id(无 id 的全局配置可空) **/
+ targetId?: string | null;
+ /** 展示名(账号名 / 域名 / 邮箱地址 / webhook url) **/
+ targetName?: string | null;
+ /** JSON 补充信息(不含 secret) **/
+ detail?: Record | null;
+ /** 操作账号(Console 登录名);系统自动操作传 system **/
+ operator?: string;
+}
+
+/** # 函数
+ * 写一条操作日志(旁路:任何失败只打日志,不抛错)
+ * 参数: env - 运行环境;input - 日志内容
+ */
+export async function logOperation(env: Env, input: OperationLogInput): Promise {
+ try {
+ await env.DB.prepare(
+ `INSERT INTO operation_logs (id, action, target_type, target_id, target_name, detail, operator, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .bind(
+ ids.log(),
+ input.action,
+ input.targetType,
+ input.targetId ?? null,
+ input.targetName ?? null,
+ input.detail && Object.keys(input.detail).length > 0 ? JSON.stringify(input.detail) : null,
+ input.operator ?? "system",
+ nowIso(),
+ )
+ .run();
+ } catch (err) {
+ console.error(
+ JSON.stringify({ t: nowIso(), lvl: "error", msg: "operation log write failed", action: input.action, error: String(err) }),
+ );
+ }
+}
+
+/** # 函数
+ * 按用户 id 查登录名(日志的 operator 字段用)
+ * 返回值: 用户名;查不到回退为用户 id
+ */
+export async function operatorName(env: Env, userId: string | undefined): Promise {
+ if (!userId) return "system";
+ const row = await env.DB.prepare("SELECT username FROM users WHERE id = ?")
+ .bind(userId)
+ .first<{ username: string }>();
+ return row?.username ?? userId;
+}
diff --git a/console/src/services/webhook_delivery.ts b/console/src/services/webhook_delivery.ts
index b7427ec..b6c0066 100644
--- a/console/src/services/webhook_delivery.ts
+++ b/console/src/services/webhook_delivery.ts
@@ -24,6 +24,7 @@ import {
} from "../../../shared/src/index.ts";
import { htmlToMarkdown } from "../../../shared/src/index.ts";
import { encryptSecret, decryptSecret, getAesKey, getSetting, setSetting } from "../db.ts";
+import { logOperation } from "./logs.ts";
import { nowIso } from "../util.ts";
/** # 成员变量: 单级 Webhook 配置(解析结果) **/
@@ -317,6 +318,7 @@ interface DueDeliveryRow {
markdown_body: string | null;
received_at: string;
expires_at: string;
+ retained: number;
}
/** # 函数
@@ -333,7 +335,7 @@ export async function processDueDeliveries(
const due = await env.DB.prepare(
`SELECT d.id AS delivery_id, d.url, d.headers, d.key_enc, d.attempt_count,
e.id AS email_id, e.mailbox_id, e.from_address, e.to_addresses, e.subject, e.text_body,
- e.html_body, e.markdown_body, e.received_at, e.expires_at
+ e.html_body, e.markdown_body, e.received_at, e.expires_at, e.retained
FROM webhook_deliveries d
JOIN emails e ON e.id = d.email_id
WHERE d.status IN ('pending', 'failed')
@@ -349,8 +351,8 @@ export async function processDueDeliveries(
let failed = 0;
let exhausted = 0;
for (const row of due.results) {
- if (row.expires_at <= now) {
- // 邮件已过期:投递失去意义,直接结束(数据最小化)
+ if (row.expires_at <= now && !row.retained) {
+ // 邮件已过期:投递失去意义,直接结束(数据最小化);「保留」的邮件不受过期约束
await env.DB.prepare("UPDATE webhook_deliveries SET status = 'exhausted', last_error = 'email expired' WHERE id = ?")
.bind(row.delivery_id)
.run();
@@ -390,6 +392,14 @@ export async function processDueDeliveries(
)
.bind(attempts, nowIso(), row.delivery_id)
.run();
+ // 投递终局写操作日志(Webhook 记录):重试中间态不记,只记成功/彻底失败
+ await logOperation(env, {
+ action: "webhook.deliver",
+ targetType: "webhook",
+ targetId: row.delivery_id,
+ targetName: row.url,
+ detail: { status: "success", target_email_id: row.email_id, subject: row.subject, attempts },
+ });
success++;
} else if (attempts >= WEBHOOK_MAX_ATTEMPTS) {
await env.DB.prepare(
@@ -397,6 +407,13 @@ export async function processDueDeliveries(
)
.bind(attempts, result.error ?? "unknown", row.delivery_id)
.run();
+ await logOperation(env, {
+ action: "webhook.deliver_failed",
+ targetType: "webhook",
+ targetId: row.delivery_id,
+ targetName: row.url,
+ detail: { status: "exhausted", target_email_id: row.email_id, subject: row.subject, attempts, error: result.error ?? "unknown" },
+ });
exhausted++;
} else {
await env.DB.prepare(
diff --git a/migrations/0004_operation_logs_retain.sql b/migrations/0004_operation_logs_retain.sql
new file mode 100644
index 0000000..9bd4ba5
--- /dev/null
+++ b/migrations/0004_operation_logs_retain.sql
@@ -0,0 +1,21 @@
+-- v2.5.0:操作日志 + 邮件「保留」(不再过期)
+-- - operation_logs:记录 Console 内所有人工/系统操作(账号 / 域名 / 邮箱 / Webhook),
+-- 供「日志」页回看;detail 只存非敏感元信息(secret/token 永不入库该表)。
+-- - emails.retained:1 = 用户点「保留」,过期清理(Cron TTL)跳过该邮件,永不过期。
+
+/** # 数据库表:操作日志(Console 内所有操作的审计流水) **/
+CREATE TABLE IF NOT EXISTS operation_logs (
+ id TEXT PRIMARY KEY,
+ action TEXT NOT NULL, -- account.create / account.delete / domain.setup / mailbox.create / webhook.save / webhook.deliver ...
+ target_type TEXT NOT NULL, -- account | domain | mailbox | email | webhook
+ target_id TEXT, -- 被操作资源 id(全局 webhook 等无 id 时为 NULL)
+ target_name TEXT, -- 展示名(账号名 / 域名 / 邮箱地址 / webhook url)
+ detail TEXT, -- JSON 补充信息(不含任何 secret)
+ operator TEXT NOT NULL DEFAULT '', -- 操作账号(Console 登录名;系统自动操作为 system)
+ created_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_operation_logs_created ON operation_logs(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_operation_logs_type ON operation_logs(target_type);
+
+-- 邮件保留标记(1 = 永不过期,Cron TTL 清理跳过)
+ALTER TABLE emails ADD COLUMN retained INTEGER NOT NULL DEFAULT 0;
diff --git a/package.json b/package.json
index daa0666..5cd1e8b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "wmailhub",
- "version": "2.4.1",
+ "version": "2.5.0",
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
"private": true,
"type": "module",
diff --git a/shared/src/ids.ts b/shared/src/ids.ts
index 6d43fd3..83acbf5 100644
--- a/shared/src/ids.ts
+++ b/shared/src/ids.ts
@@ -33,4 +33,5 @@ export const ids = {
apiKey: () => newId("key"),
oauthState: () => newId("st"),
credential: () => newId("cred"),
+ log: () => newId("oplog"),
};
diff --git a/tests/logs.test.ts b/tests/logs.test.ts
new file mode 100644
index 0000000..b89d88b
--- /dev/null
+++ b/tests/logs.test.ts
@@ -0,0 +1,276 @@
+/**
+ * 操作日志 + 邮件保留(v2.5.0):
+ * - 资源路由的写操作落 operation_logs(操作账号 / 动作 / 对象)
+ * - POST /api/emails/retain 保留邮件;Cron TTL 清理跳过已保留
+ * - Webhook 投递终局写日志;日志表最多保留 10000 条(housekeeping 裁剪)
+ */
+import { describe, expect, test } from "bun:test";
+import { cleanupExpiredEmails, housekeeping } from "../console/src/services/cleanup.ts";
+import { ingestEmail } from "../console/src/services/email_ingest.ts";
+import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts";
+import { authedFetch, loginCookie, makeTestEnv, TEST_MAIL, type TestEnv } from "./helpers.ts";
+
+/** # 成员变量: 日志行结构(直接查库断言用) **/
+interface LogRow {
+ action: string;
+ target_type: string;
+ target_id: string | null;
+ target_name: string | null;
+ operator: string;
+}
+
+/** # 函数
+ * 查最新一条指定动作的日志
+ */
+async function latestLog(t: TestEnv, action: string): Promise {
+ return await t.env.DB.prepare(
+ "SELECT action, target_type, target_id, target_name, operator FROM operation_logs WHERE action = ? ORDER BY created_at DESC, id DESC LIMIT 1",
+ )
+ .bind(action)
+ .first();
+}
+
+describe("操作日志(账号/域名/邮箱/Webhook)", () => {
+ test("创建邮箱写日志:operator=登录名,对象为邮箱地址", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ const resp = await authedFetch(t, cookie, "http://localhost/api/mailboxes", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ domain_id: t.domainId, address: "logger" }),
+ });
+ expect(resp.status).toBe(201);
+ const row = await latestLog(t, "mailbox.create");
+ expect(row?.operator).toBe("admin");
+ expect(row?.target_type).toBe("mailbox");
+ expect(row?.target_name).toBe("logger@example.test");
+ });
+
+ test("删除邮箱写日志;API 层无对应操作时不产生日志", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ // 纯读操作不写日志
+ await authedFetch(t, cookie, "http://localhost/api/mailboxes");
+ expect(await latestLog(t, "mailbox.delete")).toBeNull();
+ const del = await authedFetch(t, cookie, `http://localhost/api/mailboxes/${t.mailboxId}`, { method: "DELETE" });
+ expect(del.status).toBe(200);
+ const row = await latestLog(t, "mailbox.delete");
+ expect(row?.target_name).toBe("*@example.test");
+ expect(row?.operator).toBe("admin");
+ });
+
+ test("删除账号(软删)写日志;批量删除记一条汇总(含数量)", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}`, { method: "DELETE" });
+ const row = await latestLog(t, "account.delete");
+ expect(row?.target_name).toBe("test-account");
+ expect(row?.target_type).toBe("account");
+
+ const batch = await authedFetch(t, cookie, "http://localhost/api/accounts/batch-delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: ["acct_no_such"] }),
+ });
+ expect(batch.status).toBe(200);
+ // 全部失败(deleted=0)不写日志
+ expect(await latestLog(t, "account.batch_delete")).toBeNull();
+ });
+
+ test("域名级 Webhook 保存/清除写 Webhook 记录", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ const save = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url: "https://hooks.example.com/wmh" }),
+ });
+ expect(save.status).toBe(200);
+ const saved = await latestLog(t, "webhook.save");
+ expect(saved?.target_name).toBe("https://hooks.example.com/wmh");
+ expect(saved?.target_type).toBe("webhook");
+
+ const clear = await authedFetch(t, cookie, `http://localhost/api/domains/${t.domainId}/webhook`, { method: "DELETE" });
+ expect(clear.status).toBe(200);
+ expect((await latestLog(t, "webhook.clear"))?.target_name).toBe("example.test");
+ });
+
+ test("Webhook 投递终局写日志:成功记 deliver,重试耗尽记 deliver_failed", async () => {
+ const t = await makeTestEnv();
+ const recv = Bun.serve({
+ port: 0,
+ fetch: () => new Response("no", { status: 500 }),
+ });
+ const cookie = await loginCookie(t);
+ await authedFetch(t, cookie, "http://localhost/api/settings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ webhook: { url: `http://127.0.0.1:${recv.port}/hook` } }),
+ });
+ await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+ // 重试上限 5 次(指数退避):每轮把 next_retry_at 拨回过去,立即触发下一轮
+ for (let round = 0; round < 5; round++) {
+ if (round > 0) {
+ await t.env.DB.prepare("UPDATE webhook_deliveries SET next_retry_at = ?").bind(new Date(Date.now() - 1000).toISOString()).run();
+ }
+ await processDueDeliveries(t.env, undefined, 20, true);
+ }
+ const failed = await latestLog(t, "webhook.deliver_failed");
+ expect(failed?.target_type).toBe("webhook");
+ expect(failed?.operator).toBe("system");
+ expect(await latestLog(t, "webhook.deliver")).toBeNull();
+ recv.stop(true);
+ });
+
+ test("GET /api/logs:时间倒序 + target_type 筛选 + 分页", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ // 造两类日志:1 条邮箱 + 1 条邮件删除
+ await authedFetch(t, cookie, "http://localhost/api/mailboxes", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ domain_id: t.domainId, address: "logfilter" }),
+ });
+ const email = await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+ await authedFetch(t, cookie, `http://localhost/api/emails/${email.id}`, { method: "DELETE" });
+
+ const all = await authedFetch(t, cookie, "http://localhost/api/logs?limit=10");
+ expect(all.status).toBe(200);
+ const allBody = (await all.json()) as { items: LogRow[]; total: number };
+ expect(allBody.total).toBe(2);
+ expect(allBody.items[0]?.action).toBe("email.delete");
+
+ const filtered = await authedFetch(t, cookie, "http://localhost/api/logs?target_type=mailbox");
+ const filteredBody = (await filtered.json()) as { items: LogRow[]; total: number };
+ expect(filteredBody.total).toBe(1);
+ expect(filteredBody.items[0]?.action).toBe("mailbox.create");
+ });
+});
+
+describe("邮件保留(retained,永不过期)", () => {
+ test("保留接口置位 retained;Cron TTL 清理跳过已保留,未保留的照删", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ const first = await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+ const second = await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+
+ const retain = await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: [first.id] }),
+ });
+ expect(retain.status).toBe(200);
+ expect(((await retain.json()) as { retained: number }).retained).toBe(1);
+ // 重复保留幂等:不再计数
+ const again = await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: [first.id] }),
+ });
+ expect(((await again.json()) as { retained: number }).retained).toBe(0);
+
+ // 两封都改为已过期,跑清理
+ const past = new Date(Date.now() - 60_000).toISOString();
+ await t.env.DB.prepare("UPDATE emails SET expires_at = ?").bind(past).run();
+ const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] };
+ await cleanupExpiredEmails(t.env, stats);
+ expect(stats.expiredEmails).toBe(1);
+ const kept = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(first.id).first();
+ expect(kept).not.toBeNull();
+ const gone = await t.env.DB.prepare("SELECT id FROM emails WHERE id = ?").bind(second.id).first();
+ expect(gone).toBeNull();
+ // 保留动作写日志
+ expect((await latestLog(t, "email.retain"))?.target_type).toBe("email");
+ });
+
+ test("列表接口带出 retained;已保留的过期邮件不再被标记 muted 判定依据(字段可判)", async () => {
+ const t = await makeTestEnv();
+ const cookie = await loginCookie(t);
+ const email = await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+ await authedFetch(t, cookie, "http://localhost/api/emails/retain", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: [email.id] }),
+ });
+ const list = (await (await authedFetch(t, cookie, "http://localhost/api/emails")).json()) as {
+ items: { id: string; retained: number }[];
+ };
+ expect(list.items.find((e) => e.id === email.id)?.retained).toBe(1);
+ });
+
+ test("已保留邮件的 Webhook 投递不因 expires_at 过去而放弃", async () => {
+ const t = await makeTestEnv();
+ let hits = 0;
+ const recv = Bun.serve({
+ port: 0,
+ fetch: () => {
+ hits++;
+ return Response.json({ ok: true });
+ },
+ });
+ await t.env.DB.prepare(
+ "INSERT INTO settings (key, value) VALUES ('global_webhook', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+ )
+ .bind(JSON.stringify({ url: `http://127.0.0.1:${recv.port}/hook`, headers: {} }))
+ .run();
+ await ingestEmail(t.env, {
+ raw: new TextEncoder().encode(TEST_MAIL),
+ mailFrom: "a@b.c",
+ rcptTo: "hello@example.test",
+ accountId: t.accountId,
+ });
+ // 邮件已过期 + 已保留
+ await t.env.DB.prepare("UPDATE emails SET expires_at = ?, retained = 1").bind(new Date(Date.now() - 60_000).toISOString()).run();
+ const stats = await processDueDeliveries(t.env, undefined, 20, true);
+ expect(stats.success).toBe(1);
+ expect(hits).toBe(1);
+ recv.stop(true);
+ });
+});
+
+describe("日志上限(10000 条)", () => {
+ test("housekeeping 裁剪:只留最新 10000 条", async () => {
+ const t = await makeTestEnv();
+ const now = new Date().toISOString();
+ const rows: string[] = [];
+ for (let i = 0; i < 10020; i++) {
+ rows.push(`('oplog_${String(i).padStart(6, "0")}', 'mailbox.create', 'mailbox', NULL, 'x', NULL, 'admin', '${now}')`);
+ }
+ t.env.DB.exec(`INSERT INTO operation_logs (id, action, target_type, target_id, target_name, detail, operator, created_at) VALUES ${rows.join(",")}`);
+ const before = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
+ expect(before?.n).toBe(10020);
+ const stats = { expiredEmails: 0, deferredEmails: 0, orphanAttachments: 0, orphanDeliveries: 0, housekeeping: 0, errors: [] };
+ await housekeeping(t.env, stats);
+ expect(stats.housekeeping).toBe(20);
+ const after = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM operation_logs").first<{ n: number }>();
+ expect(after?.n).toBe(10000);
+ // 留下的是最新的(前 20 条被裁掉)
+ const oldest = await t.env.DB.prepare("SELECT id FROM operation_logs ORDER BY created_at ASC, id ASC LIMIT 1").first<{ id: string }>();
+ expect(oldest?.id).toBe("oplog_000020");
+ });
+});
diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts
index 8ff7f1f..a401c14 100644
--- a/tests/oauth.test.ts
+++ b/tests/oauth.test.ts
@@ -704,6 +704,16 @@ describe("API Token 方式添加账号(与 OAuth 并列的第二种接入方式)
expect(conn?.provider).toBe("cloudflare-token");
expect(conn!.encrypted_access_token).not.toContain("cf-user-api-token-abc");
expect(conn!.expires_at).toBeNull(); // API Token 长期有效
+
+ // 操作日志:v2.5.0 起账号接入记 operation_logs(操作账号/动作/对象)
+ const log = await t.env.DB.prepare(
+ "SELECT action, target_id, target_name, operator FROM operation_logs WHERE target_id = ?",
+ )
+ .bind(body.account_id)
+ .first<{ action: string; target_id: string; target_name: string; operator: string }>();
+ expect(log?.action).toBe("account.create");
+ expect(log?.target_name).toBe("我的主账号");
+ expect(log?.operator).toBe("admin");
});
test("Token 无效 / 账户 ID 对不上:当场报错,不留下僵尸账号", async () => {
diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts
index d83e796..041fc65 100644
--- a/tests/ui-smoke.ts
+++ b/tests/ui-smoke.ts
@@ -55,6 +55,7 @@ const pages = [
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
{ path: "/inboxs", expect: "收件箱", shot: "06-inboxs.png" },
+ { path: "/logs", expect: "日志", shot: "06b-logs.png" },
];
for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
@@ -143,32 +144,39 @@ for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
}
-// 6b. 添加账号模态窗口:OAuth / API Token 两种授权方式 + 各自说明
+// 6b. 添加账号模态窗口:OAuth / API Token 同窗(Radio 切换),只留必要字段
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table", { timeout: 8000 });
+check(
+ "账号列表不再有「Email Worker」列(统一命名,无需展示)",
+ (await page.locator(".ant-table-thead th:has-text('Email Worker')").count()) === 0,
+);
await page.click("button:has-text('添加账号')");
await page.waitForSelector(".ant-modal:has-text('添加 Cloudflare 账号')", { timeout: 8000 });
-const addTabs = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
+const modeLabels = await page.locator(".ant-modal .ant-radio-button-wrapper").allInnerTexts();
check(
- "添加账号模态窗口含 OAuth / API Token 两种授权方式",
- addTabs.some((t) => t.includes("OAuth")) && addTabs.some((t) => t.includes("API Token")),
- addTabs.join(" | "),
+ "添加账号模态窗口含 OAuth / API Token 两种授权方式(同窗按钮组)",
+ modeLabels.some((t) => t.includes("OAuth")) && modeLabels.some((t) => t.includes("API Token")),
+ modeLabels.join(" | "),
);
check(
- "OAuth 方式带说明(不需在本系统保存密钥)",
- (await page.locator(".ant-modal :text('不需要在本机保存任何密钥')").count()) > 0,
+ "窗口内不再有说明文字(Alert)与账户 ID 字段",
+ (await page.locator(".ant-modal .ant-alert").count()) === 0 &&
+ (await page.locator(".ant-modal :text('账户 ID')").count()) === 0,
+);
+// OAuth 模式:只有「前往 Cloudflare 授权」按钮
+check(
+ "OAuth 模式默认展示「前往 Cloudflare 授权」按钮",
+ (await page.locator(".ant-modal button:has-text('前往 Cloudflare 授权')").count()) === 1,
);
await page.screenshot({ path: `${SHOT_DIR}/12a-add-account-oauth.png` });
-await page.click(".ant-modal .ant-tabs-tab:has-text('API Token')");
+// 切到 API Token:账户名称 + API Token 两个字段
+await page.click(".ant-modal .ant-radio-button-wrapper:has-text('API Token')");
await page.waitForSelector(".ant-modal :text('账户名称')", { timeout: 8000 });
check(
- "API Token 方式含 账户名称 / 账户 ID / API Token 三个字段",
- (await page.locator(".ant-modal :text('账户 ID')").count()) > 0 &&
- (await page.locator(".ant-modal input[type=password]").count()) > 0,
-);
-check(
- "API Token 方式带说明(会先校验再接入)",
- (await page.locator(".ant-modal :text('确认可用后才建账号')").count()) > 0,
+ "API Token 方式只含 账户名称 / API Token 两个字段",
+ (await page.locator(".ant-modal input[type=password]").count()) === 1 &&
+ (await page.locator(".ant-modal :text('账户 ID')").count()) === 0,
);
await page.screenshot({ path: `${SHOT_DIR}/12b-add-account-token.png` });
await page.keyboard.press("Escape");
@@ -239,6 +247,38 @@ if ((await setupBtn.count()) > 0) {
check("点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)", false, "找不到配置按钮");
}
+// 6e. 域名列表复制按钮
+await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
+await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
+check(
+ "域名列表域名右侧带复制按钮",
+ (await page.locator(".ant-table-tbody button.copy-btn").count()) > 0,
+);
+await page.waitForTimeout(400);
+await page.screenshot({ path: `${SHOT_DIR}/15-domains-copy.png` });
+
+// 6f. 收件箱:发件人/收件人复制按钮 + 「操作」列保留按钮 + 批量保留
+await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
+await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
+check(
+ "列表表头是「收件人」(不再叫「邮箱」)",
+ (await page.locator(".ant-table-thead th:has-text('收件人')").count()) === 1 &&
+ (await page.locator(".ant-table-thead th:has-text('邮箱')").count()) === 0,
+);
+const inboxCopyBtns = await page.locator(".ant-table-tbody button.copy-btn").count();
+check("发件人与收件人右侧都有复制按钮", inboxCopyBtns >= 2, `copy-btn x${inboxCopyBtns}`);
+check(
+ "「操作」列有保留按钮(图钉,无边框)",
+ (await page.locator(".ant-table-thead th:has-text('操作')").count()) === 1 &&
+ (await page.locator(".ant-table-tbody button.pin-btn").count()) > 0,
+);
+// 勾选一行 → 工具条出现「保留选中」
+await page.locator(".ant-table-tbody .ant-checkbox-input").first().click({ force: true });
+await page.waitForSelector("button:has-text('保留选中')", { timeout: 6000 });
+check("勾选后工具条出现「保留选中(批量保留)」按钮", true);
+await page.waitForTimeout(400);
+await page.screenshot({ path: `${SHOT_DIR}/16-inbox-retain.png` });
+
await browser.close();
const failed = results.filter((r) => !r.ok);