v2.5.0:操作日志 + 邮件保留 + 账号弹窗精简 + 复制按钮推广

- 操作日志:operation_logs 表(0004 迁移)记录 账号/域名/邮箱/Webhook 全部写操作,
  含操作账号(Console 登录名,系统动作为 system);/logs 日志页(类型筛选+搜索+分页);
  上限 10000 条,Cron housekeeping 超限删最旧
- 邮件保留:emails.retained;POST /api/emails/retain(单条/批量);
  TTL 清理与 Webhook 投递过期判定均跳过已保留;/inboxs 操作列图钉按钮 + 批量保留
- 添加账号弹窗:OAuth 与 API Token 同窗按钮组切换,去掉说明文字与账户 ID 字段
  (账户 ID 以 Token 实际所属账户为准);账号列表去掉 Email Worker 列
- 复制按钮推广:CopyText 组件;/domains 域名列、/inboxs 发件人与收件人(原「邮箱」改名)列
- 测试:104 bun tests(+10 日志/保留)、ui-smoke 35 项(+logs 页/新弹窗/复制/保留)
This commit is contained in:
bigcode
2026-10-01 00:25:38 +08:00
parent 8c5ed7fc8b
commit 8b255ef2e0
22 changed files with 1240 additions and 228 deletions
+13
View File
@@ -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) {
+277 -21
View File
@@ -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<numbe
/** # 函数
* 软删账号:撤销 Worker credential + 标记 revoked(与 DELETE /accounts/:id 同一语义)
* 返回值: 账号存在且未撤销时 true
* 返回值: { deleted } 为账号存在且未撤销时 true;name 用于操作日志展示
*/
async function revokeAccount(env: Env, id: string): Promise<boolean> {
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<boolean> {
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 });
});
}
+9 -5
View File
@@ -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) {
+13 -4
View File
@@ -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<void> {
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<voi
}
}
/** # 成员变量: 操作日志保留上限(超出删最旧;用户指定 10000 条) **/
const OPERATION_LOG_MAX = 10000;
/** # 函数
* 琐碎清理:过期 nonce、过期 OAuth state
* 琐碎清理:过期 nonce、过期 OAuth state、操作日志超限裁剪
*/
export async function housekeeping(env: Env, stats: CleanupStats): Promise<void> {
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);
}
+69
View File
@@ -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<string, unknown> | null;
/** 操作账号(Console 登录名);系统自动操作传 system **/
operator?: string;
}
/** # 函数
* 写一条操作日志(旁路:任何失败只打日志,不抛错)
* 参数: env - 运行环境;input - 日志内容
*/
export async function logOperation(env: Env, input: OperationLogInput): Promise<void> {
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<string> {
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;
}
+20 -3
View File
@@ -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(