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 }