Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useMailStore } from '../stores/mailStore';
|
|
import { useSessionStore } from '../stores/sessionStore';
|
|
import { useUIStore } from '../stores/uiStore';
|
|
import type { Mail } from '../types';
|
|
import { ShieldIcon, PaperclipIcon } from './icons';
|
|
|
|
export default function MailList() {
|
|
const viewMode = useUIStore(s => s.viewMode);
|
|
const cancelCompose = useUIStore(s => s.cancelCompose);
|
|
|
|
const inbox = useMailStore(s => s.inbox);
|
|
const sent = useMailStore(s => s.sent);
|
|
const currentMail = useMailStore(s => s.currentMail);
|
|
const selectMail = useMailStore(s => s.selectMail);
|
|
const fetchInbox = useMailStore(s => s.fetchInbox);
|
|
const fetchSent = useMailStore(s => s.fetchSent);
|
|
const clearSession = useSessionStore(s => s.clearSession);
|
|
|
|
useEffect(() => {
|
|
if (viewMode === 'sent') fetchSent();
|
|
else if (viewMode === 'inbox') fetchInbox('all');
|
|
}, [viewMode]);
|
|
|
|
const isSent = viewMode === 'sent';
|
|
const list = isSent ? sent : inbox;
|
|
|
|
const pick = (m: Mail) => {
|
|
clearSession();
|
|
cancelCompose();
|
|
selectMail(m);
|
|
};
|
|
|
|
return (
|
|
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
|
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
|
|
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
|
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
|
{list.map(m => (
|
|
<MailItem
|
|
key={m.mail_id}
|
|
mail={m}
|
|
active={currentMail?.mail_id === m.mail_id}
|
|
showTo={isSent}
|
|
onClick={() => pick(m)}
|
|
/>
|
|
))}
|
|
{list.length === 0 && (
|
|
<p className="text-xs text-gray-400 text-center py-6">暂无邮件</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MailItem({
|
|
mail,
|
|
active,
|
|
showTo,
|
|
onClick
|
|
}: {
|
|
mail: Mail;
|
|
active: boolean;
|
|
showTo: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const isPermission = mail.mail_type === 'permission_request';
|
|
const isUnread = mail.status === 'unread';
|
|
const ccCount = mail.cc_list?.length ?? 0;
|
|
const attachCount = mail.attachments?.length ?? 0;
|
|
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
const peer = showTo
|
|
? `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`
|
|
: `${mail.from_name}${mail.from_workspace ? '@' + mail.from_workspace : ''}`;
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
|
|
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`text-xs truncate flex-1 font-mono ${
|
|
isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'
|
|
}`}
|
|
>
|
|
{showTo ? '→ ' : ''}
|
|
{peer}
|
|
</span>
|
|
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
|
{isPermission && (
|
|
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
|
<ShieldIcon className="w-2.5 h-2.5" />
|
|
权限
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`text-xs truncate ${
|
|
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
|
}`}
|
|
>
|
|
{mail.subject}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{mail.session_alias && (
|
|
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
|
|
)}
|
|
{ccCount > 0 && (
|
|
<span className="text-[10px] text-gray-400">抄送 {ccCount}</span>
|
|
)}
|
|
{attachCount > 0 && (
|
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
|
<PaperclipIcon className="w-2.5 h-2.5" />
|
|
{attachCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|