Files
MailUI4Agents/server/internal/handler/forward_test.go
JianFeeeee d9d81b94b1 fix(forward): 引用里的「转发自」不再是 name@name,改用会话的 workspace/alias 拼
用户(指着转发出来的那封信):「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」

成因是**两个错叠在一起**:
  ① 写入侧遗留:发信路径给 Agent 存 from_workspace 存的是 **agent 名**而不是路径
     (实测近两天:zcode 10 封全是 'zcode'、homeagent 'homeagent'、opencode 'opencode';
      pi 399 封有 4 种值、dsh 116 封有 3 种 —— 混着真实路径与自己的名字);
  ② 展示侧无脑拼 `name@from_workspace` ⇒ 两者一乘就是 `zcode@zcode`,
     而**那永远不是一个地址**(地址是 name@path.session)。
被转发那封的会话工作区其实是 `/home`、别名 `zcode-打个招呼`,所以正确形式是
`zcode@/home.zcode-打个招呼`(用户说的"原本是 zcode@/home…"就是这个)。

改法:
- quoteBody 增加两个入参(会话 workspace/alias),转发处理器用
  repo.SessionWorkspaceOf / SessionAliasOf 取(都是既有函数,空串语义是"不知道");
- 新增 senderAddress:优先用会话的 workspace+alias 拼 `name@path.session`
  (空 path 时给平台形式 `name@.alias`);**path 与 name 相同时当"不知道"丢掉** ——
  宁可只渲染 `zcode`,也不渲染一个看着像地址其实不是的东西。

判据:新增 TestQuoteBodySenderAddressNeverNameAtName(三条形态:正常地址 / 退化不许 name@name /
空 path 的 .alias 形式);旧的 TestQuoteBodyPrefixesEveryLine 改走退化路径以保持它原本的意图。
变异:去掉"拒绝 name@name"那段 → 红;复原 → 包内全绿。

▲ 仍未部署(与上一笔时间修复一起卡在同一个地方):/opt/agentmail 现在对我不可写
(touch 都被拒),且有 08:57 留下的 /opt/agentmail/.deploy.lock。
需要能写那儿的人:rm -f /opt/agentmail/.deploy.lock && bash deploy/redeploy-gateway.sh
2026-09-15 09:38:59 +08:00

132 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// 转发主题不能无限叠加 Fwd: 前缀,否则转发几轮后主题栏全是前缀。
func TestForwardSubject(t *testing.T) {
cases := []struct{ custom, original, want string }{
{"", "修复登录态", "Fwd: 修复登录态"},
{"", "Fwd: 修复登录态", "Fwd: 修复登录态"}, // 已有前缀不再叠加
{"自定义主题", "修复登录态", "自定义主题"},
{" ", "修复登录态", "Fwd: 修复登录态"}, // 全空白视为未指定
}
for _, c := range cases {
if got := forwardSubject(c.custom, c.original); got != c.want {
t.Errorf("forwardSubject(%q, %q) = %q, want %q", c.custom, c.original, got, c.want)
}
}
}
// 引用块必须逐行加 "> ":原文含代码块或列表时,
// 只有逐行前缀才能在任何 Markdown 渲染器里保持引用语义。
func TestQuoteBodyPrefixesEveryLine(t *testing.T) {
m := &models.Mail{
ID: uuid.New(),
FromName: "opencode",
FromWorkspace: "/root",
Subject: "巡检结果",
Body: "第一行\n\n```go\nfmt.Println(1)\n```\n- 列表项",
CreatedAt: time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC),
CCList: []models.Address{
{Name: "pi", Path: "root", Raw: "pi@root.new"},
},
}
out := quoteBody(m, "", "") // 这条用例的 fixture 里 from_workspace 本身是真路径,走退化路径
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
if line == "---" || line == "" {
continue
}
if !strings.HasPrefix(line, ">") {
t.Errorf("引用块出现未加前缀的行: %q", line)
}
}
// 元信息必须齐全,否则收件人不知道这封转发的来路
// ★ 2026-09-15:这里原先写死 "2026-09-02 10:30:00"(UTC 原样),而 fixture 是 time.UTC ——
// 等于这条断言一直在**保护那个 bug**(引用块显示 UTC 时间)。时间一项按本地算,不写死字面量。
for _, want := range []string{"opencode@/root", "巡检结果", m.CreatedAt.Local().Format("2006-01-02 15:04:05"), "pi@root.new"} {
if !strings.Contains(out, want) {
t.Errorf("引用块缺少 %q\n%s", want, out)
}
}
// 原文正文本身要在引用里
if !strings.Contains(out, "> fmt.Println(1)") {
t.Errorf("原文代码行未被引用:\n%s", out)
}
}
// 无抄送时不该渲染出空的「抄送」行。
func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
m := &models.Mail{
FromName: "admin",
Subject: "x",
Body: "y",
CreatedAt: time.Now(),
}
if strings.Contains(quoteBody(m, "", ""), "抄送") {
t.Error("无抄送时不应出现「抄送」行")
}
}
// TestQuoteBodyTimeIsLocal —— 转发引用里的「时间」必须是**本地**时间,不是 UTC 原样。
//
// 2026-09-15 用户报:引用块显示 01:23:19,而实际发生时间是 09:23:19(本机 UTC+8)。
// 库里存 UTC、界面各处都转本地,只有 quoteBody 漏了 .Local()。
//
// ★ 这条判据在 **TZ=UTC 的机器上是空转的**(本地 == UTC,两种写法同结果)——
// 本机是 UTC+8,所以这里真会红。CI 若在 UTC 下跑,它是"只能验形态"的那一类,
// 形态判据另有一条:不得与 UTC 原样相等(当两者不同时)。
func TestQuoteBodyTimeIsLocal(t *testing.T) {
// 取一个"本地与 UTC 不同"的瞬间来测(否则本判据无从区分)
m := &models.Mail{
FromName: "zcode",
Subject: "时间判定",
CreatedAt: time.Date(2026, 9, 15, 1, 23, 19, 0, time.UTC),
}
got := quoteBody(m, "", "")
want := m.CreatedAt.Local().Format("2006-01-02 15:04:05")
if !strings.Contains(got, "> **时间** "+want) {
t.Fatalf("引用里的时间不是本地时间:想要 %q,实际渲染:\n%s", want, got)
}
if u := m.CreatedAt.UTC().Format("2006-01-02 15:04:05"); u != want && strings.Contains(got, "> **时间** "+u) {
t.Fatalf("引用里出现的是 UTC 原样 %q(应为本地 %q)", u, want)
}
}
// TestQuoteBodySenderAddressNeverNameAtName —— 转发引用里的「转发自」必须是个**地址**。
//
// 2026-09-15 用户报:「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」
// 成因:发信路径给 Agent 存的 from_workspace 是 **agent 名**(zcode/homeagent/opencode 全如此),
// 而引用块按 `name@from_workspace` 拼 ⇒ zcode@zcode。
// 判据钉两条:① 用**会话**的 workspace/alias 拼出 `name@path.session`;
// ② path 与 name 相同时也**不许**渲染成 name@name(宁可只写名字)。
func TestQuoteBodySenderAddressNeverNameAtName(t *testing.T) {
legacy := &models.Mail{FromName: "zcode", FromWorkspace: "zcode", Subject: "打个招呼", CreatedAt: time.Now()}
got := quoteBody(legacy, "/home", "zcode-打个招呼")
if !strings.Contains(got, "> **转发自** zcode@/home.zcode-打个招呼") {
t.Fatalf("转发自应为 name@path.session,实际:\n%s", got)
}
if strings.Contains(got, "zcode@zcode") {
t.Fatalf("又拼出了 name@name(那不是地址):\n%s", got)
}
// 退化路径:没有会话信息、且 from_workspace 就是 agent 名 → 只写名字,不编地址
if got := quoteBody(legacy, "", ""); strings.Contains(got, "zcode@zcode") {
t.Fatalf("退化时不该出现 name@name:\n%s", got)
}
// 空 path 但别名在:平台形式是 name@.alias
pure := &models.Mail{FromName: "jianf", Subject: "x", CreatedAt: time.Now()}
if got := quoteBody(pure, "", "会话别名"); !strings.Contains(got, "> **转发自** jianf@.会话别名") {
t.Fatalf("空 path 时应为 name@.alias,实际:\n%s", got)
}
}