package repo import ( "context" "encoding/json" "sort" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) // Participant 是一条会话里的一个参与方。 // // Path 是该参与方**自己那个地址的 path 位**,不是别人的:一封主发给 dsh@/b、 // 抄送给 opencode@/a 的邮件里,两人的工作目录不同,混用会让对方在别人的目录里 // 开会话(生产上已发生过一次,见 PLUGIN-CONTRACT 9.3)。 type Participant struct { Name string `json:"name"` Path string `json:"path"` // Roles 是该参与方在这条会话里出现过的全部身份,from / to / cc 的并集。 // 用集合而非单值:同一个人常常既发过信也被抄送过,只留最后一个身份会让 // 「谁是这件事的负责人」这个判断出错。 Roles []string `json:"roles"` // MailCount 是该参与方作为发件人的邮件数。用来回答「谁还没回」—— // 参与方列表里 from 计数为 0 的那个就是还没开口的人。 MailCount int `json:"mail_count"` } // SessionParticipants 列出会话的全部参与方及各自的地址素材。 // // 为什么要逐封扫而不是看 sessions 表:**参与方是随往来增长的**。会话建立时 // 只有发件人与收件人,一封抄送、一次转发都会带进新的人。sessions 表里只有 // from_agent 一个名字,回答不了「这条线索上现在有谁」。 // // 排序:按首次出现顺序(created_at)。这让主收件人稳定排在抄送方之前, // 模型据此判断「谁是负责人、谁是配合方」——按名字排序会丢掉这个信息。 func SessionParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) { rows, err := db.DB.QueryContext(ctx, ` SELECT from_name, COALESCE(from_workspace,''), to_name, COALESCE(to_workspace,''), cc_list FROM mails WHERE session_id = $1 ORDER BY created_at ASC, mail_id ASC `, sessionID) if err != nil { return nil, err } defer rows.Close() type acc struct { p Participant roles map[string]bool order int } seen := map[string]*acc{} next := 0 // note 记录一次「某人以某身份出现」。 // // path 只在**当前为空且新值非空**时补写:同一个人可能在不同邮件里带不同 // path(先被抄送到 /a,后被主发到 /b)。保留首个非空值而不是最后一个, // 与排序口径一致(首次出现顺序),也避免一封转发把地址改指到别处。 note := func(name, path, role string, isSender bool) { if name == "" { return } a, ok := seen[name] if !ok { a = &acc{ p: Participant{Name: name, Path: path}, roles: map[string]bool{}, order: next, } next++ seen[name] = a } if a.p.Path == "" && path != "" { a.p.Path = path } a.roles[role] = true if isSender { a.p.MailCount++ } } for rows.Next() { var fromName, fromWS, toName, toWS string var ccRaw []byte if err := rows.Scan(&fromName, &fromWS, &toName, &toWS, &ccRaw); err != nil { return nil, err } // **发件人一侧不取 from_workspace 当 path。** Agent 回信时那一列存的是 // Agent 名而不是路径(历史遗留,FindOrCreateDefaultSession 的注释里也提到 // 同一个坑)。拿它拼地址会得到 `dsh@dsh.alias` 这种投不出去的东西。 note(fromName, "", "from", true) note(toName, toWS, "to", false) if len(ccRaw) > 0 { var cc []models.Address if json.Unmarshal(ccRaw, &cc) == nil { for _, c := range cc { note(c.Name, c.Path, "cc", false) } } } } if err := rows.Err(); err != nil { return nil, err } out := make([]Participant, 0, len(seen)) for _, a := range seen { a.p.Roles = sortedKeys(a.roles) out = append(out, a.p) } sort.Slice(out, func(i, j int) bool { return seen[out[i].Name].order < seen[out[j].Name].order }) return out, nil } // sortedKeys 给出稳定顺序的角色列表。 // map 迭代顺序随机,不排序的话同一条会话每次返回的 roles 顺序都不同, // 插件侧做 diff 或缓存时会误判为「参与方变了」。 func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // IsHumanOnSessionThread 判断某个用户名是否确属这条会话的线索 —— // 会话 owner,或在会话某封邮件里出现过(发件人 / 收件人 / 抄送)。 // // 用途:权限询问的决策人白名单。 // // # 为什么需要它 // // `/permission/request` 的 `to` 字段由 Agent 自由填写,原来只要它是合法的人类 // 用户名就直接采用。于是一个 Agent 可以把「是否允许执行 bash」这类审批丢给任意 // 一个与这条任务无关的人(例如管理员)。而上游刚用「管理员对这条 Agent 链的 // 上下文一无所知 —— 既不知道这个 bash 命令在做什么,也不知道拒绝后 Agent 该 // 怎么绕过去」的理由删掉了「退回第一个管理员」的兜底;同一个理由也适用于 // 「Agent 自己点名一个无关的人」。 // // 语义上,权限应当追溯到**最初分配任务的人**,也就是这条线索上的人。 // // 注意 owner 与 participations 是两处来源、缺一不可:owner 可能从未在这条会话里 // 收发过任何邮件(会话由 Agent 建立、owner 由平台指派),那种情况下只看邮件会 // 把合法 owner 判成外人。 func IsHumanOnSessionThread(ctx context.Context, sessionID uuid.UUID, name string) bool { if name == "" { return false } isHuman, err := IsHumanUser(ctx, name) if err != nil || !isHuman { return false } if owner, err := SessionOwnerUsername(ctx, sessionID); err == nil && owner == name { return true } parts, err := SessionParticipants(ctx, sessionID) if err != nil { return false } for _, p := range parts { if p.Name == name { return true } } return false }