feat: agent 邮件寻址能力全面补齐 + .new 别名替换

## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,235 @@
/**
* AgentMail Gateway 客户端 —— HTTP + SSE。
*
* 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是
* 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() {
try {
if (!existsSync(KEY_FILE)) return null;
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
} catch {
return null;
}
}
/**
* 首次安装时本地生成密钥并落盘(0600),**并把全文打印到日志**(B-1.1)。
*
* 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
*/
export function generateLocalKey(log = console.error) {
const token = randomBytes(32).toString('hex');
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
log(`已在 ${KEY_FILE} 生成本地密钥。`);
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
log(` ${token}`);
return token;
}
/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */
export function saveConfig(extra) {
try {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
let cur = {};
if (existsSync(CONFIG_FILE)) {
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
}
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
} catch (e) {
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
}
}
export class GatewayClient {
/**
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
*/
constructor({ url, agentName, agentKey, agentSecret }) {
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
this.agentName = agentName;
this.agentKey = agentKey || '';
this.agentSecret = agentSecret || '';
this.sseAbort = null;
// SSE 重连时带上,首次连接**不带**(B-1.4 / N-11):
// 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。
this.lastEventID = '';
}
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
authHeaders() {
if (this.agentKey) {
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
}
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
return res.json();
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return data;
}
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
async register() {
return this.post('/agent/register', {
name: this.agentName,
secret: this.agentSecret || '',
workspaces: [],
platform: 'pi',
});
}
/**
* 上传附件。
*
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
*
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
*/
async uploadFile(buf, filename) {
const form = new FormData();
form.append('file', new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: this.authHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
return data.attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders(),
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/**
* 建立 SSE 长连并自动重连。
*
* 手写解析而不用 EventSource:Node 内建的那个不支持自定义请求头,
* 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔)
* 比引一个依赖划算。
*
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
*/
startSSE(onEvent, log = console.error) {
this.sseAbort?.abort();
this.sseAbort = new AbortController();
const signal = this.sseAbort.signal;
const reconnect = (delay) => {
if (signal.aborted) return;
setTimeout(() => this.#connect(onEvent, reconnect, log), delay);
};
this.#connect(onEvent, reconnect, log);
}
#connect(onEvent, reconnect, log) {
const signal = this.sseAbort?.signal;
if (!signal || signal.aborted) return;
const headers = { ...this.authHeaders(), Accept: 'text/event-stream' };
// 重连时带上断点(D-7.2)。**首次连接必须不带**(N-11):那会让服务端
// 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
// 只有 lastEventID 非空(= 已经收过事件)时才是重连。
if (this.lastEventID) {
headers['Last-Event-ID'] = this.lastEventID;
log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`);
}
fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal })
.then((res) => {
if (!res.ok || !res.body) {
log(`SSE 建连失败: HTTP ${res.status}`);
return reconnect(5000);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let id = '';
let evt = '';
let data = '';
const read = () => {
reader.read().then(({ done, value }) => {
if (done) return reconnect(3000);
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('id: ')) id = line.slice(4).trim();
else if (line.startsWith('event: ')) evt = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
else if (line === '' && evt) {
// 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢,
// 否则重连会从更早的位置回放,已处理的邮件再来一遍。
if (id) this.lastEventID = id;
try { onEvent(evt, JSON.parse(data)); } catch (e) {
log(`SSE 事件处理失败: ${e?.message || e}`);
}
id = ''; evt = ''; data = '';
}
}
read();
}).catch((e) => {
if (signal.aborted) return;
log(`SSE 读取中断: ${e?.message || e}`);
reconnect(5000);
});
};
read();
})
.catch((e) => {
if (signal.aborted) return;
log(`SSE 连接错误: ${e?.message || e}`);
reconnect(5000);
});
}
stopSSE() {
this.sseAbort?.abort();
this.sseAbort = null;
}
}