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:
@@ -10,6 +10,7 @@ import {
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
HistoryOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { health, logout, me } from "./api.ts";
|
||||
import { SettingsModal } from "./components/SettingsModal.tsx";
|
||||
@@ -24,6 +25,7 @@ const MENU_ITEMS = [
|
||||
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
|
||||
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
|
||||
{ key: "/inboxs", icon: <MailOutlined />, label: "收件箱 Inboxs" },
|
||||
{ key: "/logs", icon: <HistoryOutlined />, label: "日志 Logs" },
|
||||
];
|
||||
|
||||
/** # 成员变量: 路由 -> 页面标题/副标题 **/
|
||||
@@ -32,7 +34,8 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
|
||||
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
|
||||
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
|
||||
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天),支持按域名筛选" },
|
||||
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天,可点保留永不过期),支持按域名筛选" },
|
||||
"/logs": { title: "日志", subtitle: "账号 / 域名 / 邮箱 / Webhook 的操作记录(最多保留 10000 条)" },
|
||||
};
|
||||
|
||||
/** # 成员变量: 主题切换按钮的太阳/月亮 SVG 图标 **/
|
||||
|
||||
@@ -140,13 +140,10 @@ export const updateAccount = (id: string, body: { name?: string }) =>
|
||||
|
||||
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
||||
|
||||
/** 用 Cloudflare API Token 添加账号(与 OAuth 并列的第二种接入方式)。
|
||||
* 后端会先用 token 探一次 Cloudflare 确认可用、且与填写的账户 ID 一致,再建账号并自动接入。 */
|
||||
export const createAccountByToken = (body: {
|
||||
name: string;
|
||||
cloudflare_account_id?: string;
|
||||
token: string;
|
||||
}) =>
|
||||
/** 用 Cloudflare API Token 添加账号(OAuth 之外的另一种接入方式)。
|
||||
* 后端会先用 token 探一次 Cloudflare 确认可用,再建账号并自动接入;
|
||||
* 账户 ID 以 Token 实际所属账户为准,无需(也无法)手工指定。 */
|
||||
export const createAccountByToken = (body: { name: string; token: string }) =>
|
||||
api<{ ok: boolean; account_id: string; error?: string }>("/api/accounts/token", "POST", body);
|
||||
|
||||
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
|
||||
@@ -255,6 +252,8 @@ export interface EmailRow {
|
||||
size: number;
|
||||
received_at: string;
|
||||
expires_at: string;
|
||||
/** 1 = 已保留,永不过期(Cron TTL 清理跳过) **/
|
||||
retained: number;
|
||||
attachment_count: number;
|
||||
}
|
||||
|
||||
@@ -302,6 +301,27 @@ export const batchDeleteMailboxes = (ids: string[]) =>
|
||||
export const batchDeleteEmails = (ids: string[]) =>
|
||||
api<BatchDeleteResult>("/api/emails/batch-delete", "POST", { ids });
|
||||
|
||||
/** 保留邮件(单条/批量):之后永不过期 */
|
||||
export const retainEmails = (ids: string[]) =>
|
||||
api<{ ok: boolean; retained: number }>("/api/emails/retain", "POST", { ids });
|
||||
|
||||
/* ---- 操作日志 ---- */
|
||||
|
||||
export interface OperationLogRow {
|
||||
id: string;
|
||||
action: string;
|
||||
target_type: "account" | "domain" | "mailbox" | "email" | "webhook";
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
detail: string | null;
|
||||
operator: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 操作日志查询(target_type / action / q 均可省略) */
|
||||
export const listLogs = (params: { target_type?: string; action?: string; q?: string; limit?: number; offset?: number }) =>
|
||||
api<{ items: OperationLogRow[]; total: number }>(`/api/logs${qs(params)}`);
|
||||
|
||||
/* ---- 设置 ---- */
|
||||
|
||||
export interface SettingsInfo {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 可复制文本(v2.5.0):文本右侧带复制按钮,点击写剪贴板并播放复制成功动画。
|
||||
* /mailboxes 的地址列首发,后推广到 /domains 域名列与 /inboxs 发件人/收件人列。
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { App, Button, Tooltip } from "antd";
|
||||
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
|
||||
/** # 成员变量: 组件属性 **/
|
||||
interface CopyTextProps {
|
||||
/** 要展示与复制的文本 **/
|
||||
value: string;
|
||||
/** 展示文本(缺省用 value;例如截断的长地址) **/
|
||||
display?: string;
|
||||
/** 追加在文本后面的节点(如 catch-all 标签) **/
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 可复制文本:成功时按钮弹一下并短暂变成对勾;剪贴板被拒时提示手动复制
|
||||
*/
|
||||
export function CopyText({ value, display, extra }: CopyTextProps) {
|
||||
const { message } = App.useApp();
|
||||
/** 刚复制成功的标志(播放动画 + 换对勾,1 秒后还原) **/
|
||||
const [copied, setCopied] = useState(false);
|
||||
/** 动画还原定时器(卸载/重复点击时清掉) **/
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 复制文本到剪贴板 */
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1000);
|
||||
} catch {
|
||||
// 剪贴板不可用(非 https / 权限被拒)时提示,让用户手动复制
|
||||
message.warning("浏览器拒绝了剪贴板访问,请手动复制");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, minWidth: 0, maxWidth: "100%" }}>
|
||||
<span className="mono" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{display ?? value}
|
||||
</span>
|
||||
{extra}
|
||||
<Tooltip title="复制">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={`copy-btn${copied ? " copied" : ""}`}
|
||||
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
onClick={(e) => {
|
||||
// 行点击会打开详情抽屉,复制按钮不要触发
|
||||
e.stopPropagation();
|
||||
void copy();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { AccountsPage } from "./pages/Accounts.tsx";
|
||||
import { DomainsPage } from "./pages/Domains.tsx";
|
||||
import { MailboxesPage } from "./pages/Mailboxes.tsx";
|
||||
import { InboxPage } from "./pages/Inbox.tsx";
|
||||
import { LogsPage } from "./pages/Logs.tsx";
|
||||
import { setUnauthorizedHandler } from "./api.ts";
|
||||
|
||||
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
|
||||
@@ -28,6 +29,7 @@ const router = createBrowserRouter([
|
||||
{ path: "domains", element: <DomainsPage /> },
|
||||
{ path: "mailboxes", element: <MailboxesPage /> },
|
||||
{ path: "inboxs", element: <InboxPage /> },
|
||||
{ path: "logs", element: <LogsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
|
||||
/** 账号页:添加账号(Cloudflare OAuth / API Token 同窗切换),授权后自动接入并同步域名;搜索、详情、凭据管理 */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
@@ -137,7 +136,6 @@ export function AccountsPage() {
|
||||
try {
|
||||
const res = await createAccountByToken({
|
||||
name: values.name,
|
||||
cloudflare_account_id: values.cloudflare_account_id?.trim() || undefined,
|
||||
token: values.token.trim(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -179,10 +177,7 @@ export function AccountsPage() {
|
||||
style={{ width: 280 }}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<span className="muted">
|
||||
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
|
||||
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||
</span>
|
||||
<span className="muted">接入后自动同步域名、部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。</span>
|
||||
</div>
|
||||
<Space>
|
||||
<BatchActions
|
||||
@@ -236,12 +231,6 @@ export function AccountsPage() {
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "Email Worker",
|
||||
dataIndex: "worker_id",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未部署</span>),
|
||||
},
|
||||
{
|
||||
title: "接入时间",
|
||||
dataIndex: "created_at",
|
||||
@@ -320,9 +309,9 @@ interface AddAccountModalProps {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 「添加账号」模态窗口:Cloudflare OAuth 与 API Token 两种授权方式,各自带说明。
|
||||
* 两种方式最终都走同一套自动接入流程(发现域名 -> 部署 Email Worker -> 签发 Worker 凭据),
|
||||
* 区别只在于 token 从哪来:OAuth 走用户点授权,API Token 由用户自己粘贴。
|
||||
* 「添加 Cloudflare 账号」模态窗口:Cloudflare OAuth 授权与 API Token 授权在同一个窗口,
|
||||
* 顶部按钮组切换方式,字段随方式变化 —— OAuth 只要一个「前往授权」按钮,
|
||||
* API Token 填账户名称 + Token 即可(账户 ID 由 Token 在 Cloudflare 侧实际所属账户自动确定)。
|
||||
*/
|
||||
function AddAccountModal(props: AddAccountModalProps) {
|
||||
const { open, mode, loading, form, onModeChange, onCancel, onOAuth, onTokenSubmit } = props;
|
||||
@@ -333,96 +322,41 @@ function AddAccountModal(props: AddAccountModalProps) {
|
||||
onCancel={onCancel}
|
||||
maskClosable={!loading}
|
||||
closable={!loading}
|
||||
width={620}
|
||||
width={520}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Tabs
|
||||
activeKey={mode}
|
||||
onChange={(k) => onModeChange(k as "oauth" | "token")}
|
||||
items={[
|
||||
{
|
||||
key: "oauth",
|
||||
label: (
|
||||
<span>
|
||||
<Radio.Group
|
||||
block
|
||||
value={mode}
|
||||
onChange={(e) => onModeChange(e.target.value as "oauth" | "token")}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
<Radio.Button value="oauth" style={{ width: "50%" }}>
|
||||
<SafetyOutlined /> Cloudflare OAuth 授权
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="推荐:不需要在本机保存任何密钥"
|
||||
description={
|
||||
<span>
|
||||
点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权,
|
||||
完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、
|
||||
部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。
|
||||
<br />
|
||||
前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App
|
||||
已勾选本应用需要的权限(scope 用点号分隔)。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Radio.Button>
|
||||
<Radio.Button value="token" style={{ width: "50%" }}>
|
||||
<ApiOutlined /> API Token 授权
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Divider style={{ margin: "16px 0" }} />
|
||||
{mode === "oauth" ? (
|
||||
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
|
||||
前往 Cloudflare 授权
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "token",
|
||||
label: (
|
||||
<span>
|
||||
<ApiOutlined /> API Token 授权
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
) : (
|
||||
<Form form={form} layout="vertical" requiredMark="optional">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="适合不方便走 OAuth 跳转的场景"
|
||||
description={
|
||||
<span>
|
||||
直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍
|
||||
(并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。
|
||||
Token 加密入库、不回显、不写日志。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="账户名称"
|
||||
rules={[{ required: true, message: "请输入账户名称" }]}
|
||||
>
|
||||
<Form.Item name="name" label="账户名称" rules={[{ required: true, message: "请输入账户名称" }]}>
|
||||
<Input placeholder="我的主账号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cloudflare_account_id"
|
||||
label="账户 ID"
|
||||
extra="Cloudflare 账户 ID(账户主页右侧的 Account ID)。可留空,留空则以 Token 实际所属账户为准;填错会被校验拦下。"
|
||||
>
|
||||
<Input placeholder="bf8cd983e6dfd171c1881b00b512ed5a" className="mono" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="token"
|
||||
label="API Token"
|
||||
rules={[{ required: true, message: "请输入 API Token" }]}
|
||||
extra="到 Cloudflare → My Profile → API Tokens 生成。所需权限与 OAuth 方式一致:Zone Read、Workers Scripts Edit、Email Routing Rules Edit、Zone Settings Write。"
|
||||
>
|
||||
<Form.Item name="token" label="API Token" rules={[{ required: true, message: "请输入 API Token" }]}>
|
||||
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
|
||||
{loading ? "正在校验并接入…" : "用 API Token 接入"}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -483,9 +417,7 @@ function AccountDetailDrawer({
|
||||
detail.domains.map((d) => (
|
||||
<div key={d.id}>
|
||||
<Tag color="blue">{d.name}</Tag>
|
||||
<span className="muted mono">
|
||||
{d.worker_id ?? "未配置 Email Workers"}
|
||||
</span>
|
||||
<span className="muted">{d.worker_id ? "已配置 Email Workers" : "未配置 Email Workers"}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type DomainRow,
|
||||
} from "../api.ts";
|
||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||
import { CopyText } from "../components/CopyText.tsx";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
|
||||
/** # 成员变量: 域名状态 -> Tag **/
|
||||
@@ -175,7 +176,7 @@ export function DomainsPage() {
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
columns={[
|
||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "域名", dataIndex: "name", render: (v: string) => <CopyText value={v} /> },
|
||||
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
|
||||
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载)、保留(不再过期) */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
App,
|
||||
Popconfirm,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
@@ -28,10 +31,12 @@ import {
|
||||
getEmail,
|
||||
listDomains,
|
||||
listEmails,
|
||||
retainEmails,
|
||||
type EmailDetail,
|
||||
type EmailRow,
|
||||
} from "../api.ts";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
import { CopyText } from "../components/CopyText.tsx";
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
@@ -42,37 +47,6 @@ function fmtSize(n: number): string {
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
/** # 成员变量: 表格列(分页在组件内配置) **/
|
||||
const columns = [
|
||||
{
|
||||
title: "主题",
|
||||
dataIndex: "subject",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
|
||||
},
|
||||
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
|
||||
{ title: "邮箱", dataIndex: "mailbox_address", width: 190, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "附件",
|
||||
dataIndex: "attachment_count",
|
||||
width: 70,
|
||||
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
|
||||
},
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
|
||||
{
|
||||
title: "接收时间",
|
||||
dataIndex: "received_at",
|
||||
width: 175,
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "过期时间",
|
||||
dataIndex: "expires_at",
|
||||
width: 175,
|
||||
render: (v: string) => <span className="muted">{new Date(v).toLocaleString()}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
/** # 函数
|
||||
* 收件箱页组件
|
||||
*/
|
||||
@@ -89,6 +63,8 @@ export function InboxPage() {
|
||||
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
||||
const [htmlPreview, setHtmlPreview] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
/** 批量保留进行中 **/
|
||||
const [retaining, setRetaining] = useState(false);
|
||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -119,6 +95,110 @@ export function InboxPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/** 保留邮件(单条):retained=1 后 Cron TTL 清理跳过,永不过期 */
|
||||
const retainOne = async (row: EmailRow) => {
|
||||
try {
|
||||
await retainEmails([row.id]);
|
||||
message.success("已保留,该邮件不再过期");
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保留失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 批量保留选中的邮件 */
|
||||
const retainSelected = async () => {
|
||||
setRetaining(true);
|
||||
try {
|
||||
const res = await retainEmails(selectedIds);
|
||||
message.success(`已保留 ${res.retained} 封邮件`);
|
||||
clear();
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "批量保留失败");
|
||||
} finally {
|
||||
setRetaining(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** # 成员变量: 表格列(依赖保留动作,定义在组件内) **/
|
||||
const columns = [
|
||||
{
|
||||
title: "主题",
|
||||
dataIndex: "subject",
|
||||
ellipsis: true,
|
||||
render: (v: string | null, row: EmailRow) =>
|
||||
row.retained ? (
|
||||
<span>
|
||||
<Tooltip title="已保留,不再过期">
|
||||
<PushpinFilled style={{ color: "#52c41a", marginRight: 6 }} />
|
||||
</Tooltip>
|
||||
{v ?? <span className="muted">(无主题)</span>}
|
||||
</span>
|
||||
) : (
|
||||
(v ?? <span className="muted">(无主题)</span>)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "发件人",
|
||||
dataIndex: "from_address",
|
||||
width: 230,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => (v ? <CopyText value={v} /> : "-"),
|
||||
},
|
||||
{
|
||||
title: "收件人",
|
||||
dataIndex: "mailbox_address",
|
||||
width: 230,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => (v ? <CopyText value={v} /> : <span className="muted">(未匹配)</span>),
|
||||
},
|
||||
{
|
||||
title: "附件",
|
||||
dataIndex: "attachment_count",
|
||||
width: 70,
|
||||
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
|
||||
},
|
||||
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
|
||||
{
|
||||
title: "接收时间",
|
||||
dataIndex: "received_at",
|
||||
width: 165,
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: "过期时间",
|
||||
dataIndex: "expires_at",
|
||||
width: 165,
|
||||
render: (v: string, row: EmailRow) =>
|
||||
row.retained ? <Tag color="green">永久保留</Tag> : <span className="muted">{new Date(v).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 80,
|
||||
render: (_: unknown, row: EmailRow) =>
|
||||
row.retained ? (
|
||||
<Tooltip title="已保留,不再过期">
|
||||
<Button type="text" size="small" className="pin-btn" icon={<PushpinFilled style={{ color: "#52c41a" }} />} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="保留:不再过期">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="pin-btn"
|
||||
icon={<PushpinOutlined />}
|
||||
onClick={(e) => {
|
||||
// 行点击会打开邮件详情,保留按钮不要触发
|
||||
e.stopPropagation();
|
||||
void retainOne(row);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
@@ -148,6 +228,11 @@ export function InboxPage() {
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
{count > 0 && (
|
||||
<Button icon={<PushpinOutlined />} loading={retaining} onClick={() => void retainSelected()}>
|
||||
保留选中({count})
|
||||
</Button>
|
||||
)}
|
||||
<BatchActions
|
||||
count={count}
|
||||
onClear={clear}
|
||||
@@ -178,7 +263,7 @@ export function InboxPage() {
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
rowSelection={rowSelection}
|
||||
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
|
||||
rowClassName={(row) => (row.expires_at <= new Date().toISOString() && !row.retained ? "muted" : "")}
|
||||
onRow={(row) => ({
|
||||
onClick: async (e) => {
|
||||
// 点击复选框不打开邮件详情
|
||||
@@ -211,6 +296,20 @@ export function InboxPage() {
|
||||
extra={
|
||||
detail && (
|
||||
<Space>
|
||||
{!detail.email.retained && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PushpinOutlined />}
|
||||
onClick={async () => {
|
||||
await retainEmails([detail.email.id]);
|
||||
message.success("已保留,该邮件不再过期");
|
||||
setDetail(await getEmail(detail.email.id));
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
保留
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
|
||||
{htmlPreview ? "显示文本" : "HTML 预览"}
|
||||
</Button>
|
||||
@@ -247,7 +346,11 @@ export function InboxPage() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="接收 / 过期">
|
||||
{new Date(detail.email.received_at).toLocaleString()} →{" "}
|
||||
{detail.email.retained ? (
|
||||
<Tag color="green" style={{ marginRight: 0 }}>永久保留</Tag>
|
||||
) : (
|
||||
<span className="muted">{new Date(detail.email.expires_at).toLocaleString()}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 操作日志页(/logs,v2.5.0):Console 内所有操作的审计流水。
|
||||
* 覆盖:操作账号(添加/接入/删除/轮换凭据)、操作域名(配置 Email Workers/删除)、
|
||||
* 操作邮箱(创建/更新/删除)、Webhook 记录(配置/清除/投递成功与失败)。
|
||||
* 服务端分页,可按对象类型筛选;库里最多保留 10000 条(Cron 裁剪最旧的)。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Card, Input, Select, Space, Table, Tag, Tooltip } from "antd";
|
||||
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { listLogs, type OperationLogRow } from "../api.ts";
|
||||
|
||||
/** # 成员变量: 操作对象类型 -> 中文与颜色 **/
|
||||
const TYPE_META: Record<string, { label: string; color: string }> = {
|
||||
account: { label: "账号", color: "geekblue" },
|
||||
domain: { label: "域名", color: "cyan" },
|
||||
mailbox: { label: "邮箱", color: "purple" },
|
||||
email: { label: "邮件", color: "orange" },
|
||||
webhook: { label: "Webhook", color: "blue" },
|
||||
};
|
||||
|
||||
/** # 成员变量: 动作名 -> 中文标签(未收录的动作原样展示) **/
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
"account.create": "添加账号(API Token)",
|
||||
"account.connect": "接入账号(OAuth)",
|
||||
"account.update": "更新账号",
|
||||
"account.delete": "删除账号",
|
||||
"account.batch_delete": "批量删除账号",
|
||||
"account.rotate": "轮换 Worker 凭据",
|
||||
"domain.setup": "配置 Email Workers",
|
||||
"domain.delete": "删除域名",
|
||||
"domain.batch_delete": "批量删除域名",
|
||||
"mailbox.create": "添加邮箱",
|
||||
"mailbox.update": "更新邮箱",
|
||||
"mailbox.delete": "删除邮箱",
|
||||
"mailbox.batch_delete": "批量删除邮箱",
|
||||
"email.delete": "删除邮件",
|
||||
"email.batch_delete": "批量删除邮件",
|
||||
"email.retain": "保留邮件",
|
||||
"webhook.save": "配置 Webhook",
|
||||
"webhook.clear": "清除 Webhook",
|
||||
"webhook.deliver": "Webhook 投递成功",
|
||||
"webhook.deliver_failed": "Webhook 投递失败",
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* detail JSON 压缩成一行文本(键值对用 · 连接;解析失败原样返回)
|
||||
*/
|
||||
function detailText(raw: string | null): string {
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Record<string, unknown>;
|
||||
return Object.entries(obj)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 操作日志页组件
|
||||
*/
|
||||
export function LogsPage() {
|
||||
const [items, setItems] = useState<OperationLogRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [targetType, setTargetType] = useState<string | undefined>();
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listLogs({ target_type: targetType, q, limit: pageSize, offset: (page - 1) * pageSize });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetType, q, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-toolbar">
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按类型筛选"
|
||||
style={{ width: 160 }}
|
||||
value={targetType}
|
||||
onChange={(v) => {
|
||||
setTargetType(v);
|
||||
setPage(1);
|
||||
}}
|
||||
options={Object.entries(TYPE_META).map(([value, m]) => ({ value, label: m.label }))}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索对象 / 操作账号"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<span className="muted">最多保留 10000 条,超出自动删最旧</span>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</Space>
|
||||
</div>
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${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 ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "对象",
|
||||
dataIndex: "target_name",
|
||||
ellipsis: true,
|
||||
render: (v: string | null, row: OperationLogRow) =>
|
||||
v ? (
|
||||
<Tooltip title={row.target_id ?? undefined}>
|
||||
<span className="mono">{v}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="muted">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "详情",
|
||||
dataIndex: "detail",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) =>
|
||||
v ? <span className="muted">{detailText(v)}</span> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
title: "操作账号",
|
||||
dataIndex: "operator",
|
||||
width: 120,
|
||||
render: (v: string) => (v === "system" ? <Tag>system</Tag> : v || "-"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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) => (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<span className="mono">{v}</span>
|
||||
{row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
|
||||
<Tooltip title="复制邮箱地址">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={`copy-btn${copiedId === row.id ? " copied" : ""}`}
|
||||
icon={copiedId === row.id ? <CheckOutlined /> : <CopyOutlined />}
|
||||
onClick={() => void copyAddress(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<CopyText value={v} extra={row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null} />
|
||||
),
|
||||
},
|
||||
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -33,4 +33,5 @@ export const ids = {
|
||||
apiKey: () => newId("key"),
|
||||
oauthState: () => newId("st"),
|
||||
credential: () => newId("cred"),
|
||||
log: () => newId("oplog"),
|
||||
};
|
||||
|
||||
@@ -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<LogRow | null> {
|
||||
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<LogRow>();
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
+55
-15
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user