Files
MailUI4Agents/gateway/internal/repo/repo.go
JianFeeeee 07e6b789b2 feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
2026-09-02 14:16:46 +08:00

1162 lines
40 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 repo
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ---------- Agent ----------
func CreateOrUpdateAgent(ctx context.Context, name, secret, platform string, workspaces []models.Workspace) error {
wsJSON, _ := json.Marshal(workspaces)
// 注意 DO UPDATE 里【不】碰 default_rounds:
// 那是管理员配的值,Agent 重启重新注册不应该把它冲回默认。
_, err := db.DB.ExecContext(ctx, `
INSERT INTO agents (agent_name, secret, workspaces, platform, status, last_seen)
VALUES ($1, $2, $3, $4, 'online', NOW())
ON CONFLICT (agent_name) DO UPDATE SET
secret = EXCLUDED.secret,
workspaces = EXCLUDED.workspaces,
platform = EXCLUDED.platform,
status = 'online',
last_seen = NOW()
`, name, secret, wsJSON, platform)
return err
}
func HeartbeatAgent(ctx context.Context, agentName string) (int, error) {
_, err := db.DB.ExecContext(ctx,
`UPDATE agents SET last_seen = NOW(), status = 'online' WHERE agent_name = $1`,
agentName)
if err != nil {
return 0, err
}
return CountUnread(ctx, agentName)
}
func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error) {
// 带上 default_rounds:前端补全收件人时要显示「派给它的任务默认几个来回」,
// 否则人得先去管理员页查一遍才敢派活。
q := `SELECT agent_id, agent_name, workspaces, platform, status,
COALESCE(default_rounds, 0) FROM agents`
args := []any{}
if statusFilter != "" {
q += ` WHERE status = $1`
args = append(args, statusFilter)
}
q += ` ORDER BY agent_name`
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
agents := []models.Agent{}
for rows.Next() {
var a models.Agent
var wsJSON []byte
if err := rows.Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status,
&a.DefaultRounds); err != nil {
return nil, err
}
if wsJSON != nil {
json.Unmarshal(wsJSON, &a.Workspaces)
}
agents = append(agents, a)
}
return agents, nil
}
func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error) {
var a models.Agent
var wsJSON []byte
err := db.DB.QueryRowContext(ctx,
`SELECT agent_id, agent_name, workspaces, platform, status
FROM agents WHERE agent_name = $1 AND secret = $2`,
name, secret,
).Scan(&a.ID, &a.Name, &wsJSON, &a.Platform, &a.Status)
if err != nil {
return nil, err
}
if wsJSON != nil {
json.Unmarshal(wsJSON, &a.Workspaces)
}
return &a, nil
}
// ---------- Session ----------
// CreateSession 建会话。
//
// 显式传了 alias(发信时的 session_alias 参数)= 调用方亲自命名,标为 manual,
// 平台后续自动同步不得覆盖;未传则等待平台命名,标为 platform。
func CreateSession(ctx context.Context, alias *string, fromAgent, subject string) (uuid.UUID, error) {
source := "platform"
if alias != nil && *alias != "" {
source = "manual"
}
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO sessions (session_alias, from_agent, subject, alias_source)
VALUES ($1, $2, $3, $4) RETURNING session_id`,
alias, fromAgent, subject, source,
).Scan(&id)
return id, err
}
func GetSessionByID(ctx context.Context, id uuid.UUID) (*models.Session, error) {
var s models.Session
var dismissed *string
err := db.DB.QueryRowContext(ctx,
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id,
created_at, updated_at, rename_dismissed, COALESCE(alias_source, 'platform'),
COALESCE(max_rounds, 0), COALESCE(used_rounds, 0)
FROM sessions WHERE session_id = $1`, id,
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID,
&s.CreatedAt, &s.UpdatedAt, &dismissed, &s.AliasSource,
&s.MaxRounds, &s.UsedRounds)
if err != nil {
return nil, err
}
if dismissed != nil {
s.RenameDismissed = *dismissed
}
return &s, nil
}
func TouchSession(ctx context.Context, id uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET updated_at = NOW(), status = 'active' WHERE session_id = $1`, id)
return err
}
// UpdateSessionAlias 手工改名(人显式指定)。
//
// 同时把 alias_source 标为 'manual':人的选择优先于平台自动命名。
// 否则平台下一次 session.updated 会把人刚定的名字冲掉,
// 人上一秒记住的寻址地址下一秒失效。
func UpdateSessionAlias(ctx context.Context, id uuid.UUID, alias string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET session_alias = $1, alias_source = 'manual', updated_at = NOW()
WHERE session_id = $2`,
alias, id)
return err
}
func ListSessions(ctx context.Context, statusFilter string, limit int) ([]models.Session, error) {
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
s.owner_user_id, s.created_at, s.updated_at,
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
FROM sessions s`
args := []any{}
if statusFilter != "" {
q += ` WHERE s.status = $1`
args = append(args, statusFilter)
}
q += ` ORDER BY s.updated_at DESC`
if limit > 0 {
q += fmt.Sprintf(` LIMIT %d`, limit)
}
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
sessions := []models.Session{}
for rows.Next() {
var s models.Session
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, rows.Err()
}
// ---------- Mail ----------
func CreateMail(ctx context.Context, sessionID uuid.UUID, parentMailID *uuid.UUID,
fromName, fromWorkspace, toName, toWorkspace, subject, body string, ccList []models.Address) (uuid.UUID, error) {
if ccList == nil {
ccList = []models.Address{}
}
ccJSON, _ := json.Marshal(ccList)
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
// created_at 显式给 NOW():SQLite 的 DEFAULT CURRENT_TIMESTAMP 只有秒精度,
// 同秒插入的多封邮件排序不确定(「会话里最早/最后那封」都会取错行)。
// 改 schema 的默认值只对新库生效 —— CREATE TABLE IF NOT EXISTS 不改已存在的表,
// 而 SQLite 没有 ALTER COLUMN,因此这里显式传。
`INSERT INTO mails (session_id, parent_mail_id, from_name, from_workspace,
to_name, to_workspace, subject, body, cc_list, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) RETURNING mail_id`,
sessionID, parentMailID, fromName, fromWorkspace, toName, toWorkspace, subject, body, ccJSON,
).Scan(&id)
return id, err
}
// CreatePermissionMail 创建权限请求邮件,toUser 为目标人类用户名
func CreatePermissionMail(ctx context.Context, sessionID uuid.UUID, fromName, toUser, question, body string, options []string) (uuid.UUID, error) {
optsJSON, _ := json.Marshal(options)
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, from_name, to_name, subject, body, mail_type, permission_options, created_at)
VALUES ($1, $2, $3, $4, $5, 'permission_request', $6, NOW()) RETURNING mail_id`,
sessionID, fromName, toUser, "权限请求: "+question, body, optsJSON,
).Scan(&id)
return id, err
}
// CreateDecisionMail 创建人类决策邮件(fromUser → toAgent)
func CreateDecisionMail(ctx context.Context, sessionID uuid.UUID, parentMailID uuid.UUID, fromUser, toAgent, decision, note string) (uuid.UUID, error) {
var id uuid.UUID
body := decision
if note != "" {
body = fmt.Sprintf("%s\n\n备注: %s", decision, note)
}
err := db.DB.QueryRowContext(ctx,
`INSERT INTO mails (session_id, parent_mail_id, from_name, to_name, subject, body, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) RETURNING mail_id`,
sessionID, parentMailID, fromUser, toAgent, "Re: 权限请求 - "+decision, body,
).Scan(&id)
return id, err
}
func GetMailByID(ctx context.Context, id uuid.UUID) (*models.Mail, error) {
var m models.Mail
var alias *string
var ccJSON []byte
var renameAlias, renameReason *string
err := db.DB.QueryRowContext(ctx,
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias, m.rename_alias, m.rename_reason
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.mail_id = $1`, id,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias, &renameAlias, &renameReason)
if err != nil {
return nil, err
}
if len(ccJSON) > 0 {
json.Unmarshal(ccJSON, &m.CCList)
}
if m.CCList == nil {
m.CCList = []models.Address{}
}
if alias != nil {
m.SessionAlias = *alias
}
// 改名提议随单封返回,让「谁在哪一封里提了什么」可追溯;
// 【待处理】的提议另有专用端点(GET /sessions/:id/rename-proposal)。
if renameAlias != nil {
m.RenameAlias = *renameAlias
}
if renameReason != nil {
m.RenameReason = *renameReason
}
return &m, nil
}
func MarkMailRead(ctx context.Context, id uuid.UUID) error {
_, err := db.DB.ExecContext(ctx, `UPDATE mails SET status = 'read' WHERE mail_id = $1`, id)
return err
}
func ListInbox(ctx context.Context, agentName, status string, limit int) ([]models.Mail, error) {
q := `SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR ` + db.CCHas("m.cc_list", 1) + `)
AND s.status <> 'archived'`
args := []any{agentName}
if status != "" && status != "all" {
q += ` AND m.status = $2`
args = append(args, status)
}
q += ` ORDER BY m.created_at DESC, m.mail_id DESC`
if limit > 0 {
q += fmt.Sprintf(` LIMIT %d`, limit)
}
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
mails := []models.Mail{}
for rows.Next() {
var m models.Mail
var alias *string
var ccJSON []byte
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
json.Unmarshal(ccJSON, &m.CCList)
}
if m.CCList == nil {
m.CCList = []models.Address{}
}
if alias != nil {
m.SessionAlias = *alias
}
// Body preview
if len(m.Body) > 200 {
m.BodyPreview = m.Body[:200] + "..."
} else {
m.BodyPreview = m.Body
}
mails = append(mails, m)
}
return mails, rows.Err()
}
func CountUnread(ctx context.Context, agentName string) (int, error) {
var count int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE (m.to_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND m.status = 'unread'
AND s.status <> 'archived'
`, agentName).Scan(&count)
return count, err
}
func GetSessionMails(ctx context.Context, sessionID uuid.UUID) ([]models.Mail, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1
ORDER BY m.created_at ASC, m.mail_id ASC`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
mails := []models.Mail{}
for rows.Next() {
var m models.Mail
var alias *string
var ccJSON []byte
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
json.Unmarshal(ccJSON, &m.CCList)
}
if m.CCList == nil {
m.CCList = []models.Address{}
}
if alias != nil {
m.SessionAlias = *alias
}
mails = append(mails, m)
}
return mails, rows.Err()
}
// ---------- Permission ----------
func CreatePermissionRequest(ctx context.Context, mailID, sessionID uuid.UUID, agentName, question string, options []string, contextStr string) error {
optsJSON, _ := json.Marshal(options)
_, err := db.DB.ExecContext(ctx,
`INSERT INTO permission_requests (mail_id, session_id, agent_name, question, options, context)
VALUES ($1, $2, $3, $4, $5, $6)`,
mailID, sessionID, agentName, question, optsJSON, contextStr)
return err
}
func DecidePermission(ctx context.Context, mailID uuid.UUID, decision string) (*models.PermissionRequest, error) {
var pr models.PermissionRequest
var optsJSON []byte
err := db.DB.QueryRowContext(ctx,
`UPDATE permission_requests SET result = $1, decided_at = NOW()
WHERE mail_id = $2
RETURNING request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at`,
decision, mailID,
).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal(optsJSON, &pr.Options)
// Also update the mail
_, _ = db.DB.ExecContext(context.Background(),
`UPDATE mails SET permission_result = $1, status = 'read' WHERE mail_id = $2`,
decision, mailID)
return &pr, nil
}
func ListPendingPermissions(ctx context.Context) ([]models.PermissionRequest, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at
FROM permission_requests WHERE result IS NULL
ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
reqs := []models.PermissionRequest{}
for rows.Next() {
var pr models.PermissionRequest
var optsJSON []byte
if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal(optsJSON, &pr.Options)
reqs = append(reqs, pr)
}
return reqs, nil
}
// ---------- Check permission ownership ----------
func GetPermissionByMailID(ctx context.Context, mailID uuid.UUID) (*models.PermissionRequest, error) {
var pr models.PermissionRequest
var optsJSON []byte
err := db.DB.QueryRowContext(ctx,
`SELECT request_id, mail_id, session_id, agent_name, question, options, context, result, decided_at, created_at
FROM permission_requests WHERE mail_id = $1`, mailID,
).Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal(optsJSON, &pr.Options)
return &pr, nil
}
func GetSessionMailByID(ctx context.Context, sessionID, mailID uuid.UUID) (*models.Mail, error) {
var m models.Mail
var alias *string
var ccJSON []byte
err := db.DB.QueryRowContext(ctx,
`SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
FROM mails m JOIN sessions s ON m.session_id = s.session_id
WHERE m.session_id = $1 AND m.mail_id = $2`, sessionID, mailID,
).Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias)
if err != nil {
return nil, err
}
if len(ccJSON) > 0 {
json.Unmarshal(ccJSON, &m.CCList)
}
if m.CCList == nil {
m.CCList = []models.Address{}
}
if alias != nil {
m.SessionAlias = *alias
}
return &m, nil
}
func FindSessionByAlias(ctx context.Context, alias string) (*models.Session, error) {
var s models.Session
err := db.DB.QueryRowContext(ctx,
`SELECT session_id, session_alias, from_agent, subject, status, owner_user_id, created_at, updated_at
FROM sessions WHERE session_alias = $1 AND status <> 'archived'`, alias,
).Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status, &s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, err
}
return &s, nil
}
// ErrSessionNotFound 表示三维地址里指定的 session 别名不存在(或不属于该收件人)。
// 调用方应据此回 404「无法送达」,而不是悄悄新建一个会话。
var ErrSessionNotFound = errors.New("session not found")
// FindNamedSessionFor 查找收件人 name@path 名下别名为 alias 的会话。
// 严格匹配:会话必须存在、未归档,且该收件人确实参与过该会话,否则返回 ErrSessionNotFound。
func FindNamedSessionFor(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
FROM sessions s
WHERE s.session_alias = $1
AND s.status <> 'archived'
AND EXISTS (
SELECT 1 FROM mails m
WHERE m.session_id = s.session_id
AND (m.to_name = $2 OR m.from_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
)
ORDER BY s.updated_at DESC
LIMIT 1
`, alias, name).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrSessionNotFound
}
return id, err
}
// FindOrCreateDefaultSession 实现 session 位省略时的「默认会话」语义:
// 复用 name@path 最近一次活跃的非归档会话;从未通过信则建立一个新的作为默认会话。
func FindOrCreateDefaultSession(ctx context.Context, name, path, fromAgent, subject string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
FROM sessions s
JOIN mails m ON m.session_id = s.session_id
WHERE s.status <> 'archived'
AND (m.to_name = $1 OR m.from_name = $1 OR `+db.CCHas("m.cc_list", 1)+`)
AND (COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2)
ORDER BY s.updated_at DESC
LIMIT 1
`, name, path).Scan(&id)
if err == nil {
TouchSession(ctx, id)
return id, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, err
}
return CreateSession(ctx, nil, fromAgent, subject)
}
// SessionAliasOf 返回会话别名,未命名或查询失败时返回空串。
// 仅用于响应体回显,不影响投递路径,所以吞错是可接受的。
func SessionAliasOf(ctx context.Context, id uuid.UUID) string {
var alias *string
if err := db.DB.QueryRowContext(ctx,
`SELECT session_alias FROM sessions WHERE session_id = $1`, id).Scan(&alias); err != nil {
return ""
}
if alias == nil {
return ""
}
return *alias
}
// AgentCanAccessSession 判断 Agent 是否参与过该会话(发件/收件/被抄送)。
// Agent 只能改自己参与的会话的别名,避免跨会话改名。
func AgentCanAccessSession(ctx context.Context, agentName string, sessionID uuid.UUID) (bool, error) {
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM mails m
WHERE m.session_id = $1
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
`, sessionID, agentName).Scan(&n)
return n > 0, err
}
// SyncSessionTitle 更新会话主题(Agent 平台生成的摘要标题)。
func SyncSessionTitle(ctx context.Context, id uuid.UUID, title string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET subject = $1, updated_at = NOW() WHERE session_id = $2`,
title, id)
return err
}
// SyncSessionAlias 把 Agent 平台侧的会话标识写为本侧别名。
// 平台侧标识(如 opencode 的 slug)在平台内不保证全局唯一,而本侧别名负责寻址必须唯一,
// 因此撞名时自动追加 -2、-3… 后缀而不是报错——同步是后台行为,不该因撞名失败。
// 返回最终落库的别名。该会话已持有目标别名时直接返回,不做无谓写入。
//
// **人显式定过的别名不覆盖**(alias_source = 'manual'):
// 用户刚接受了 Agent 的改名提议,或手工敲了一个名字,平台下一次 session.updated
// 不该把它冲掉 —— 那会让人上一秒记住的寻址地址下一秒失效。
// 此时返回当前别名,调用方据此知道同步未生效。
func SyncSessionAlias(ctx context.Context, id uuid.UUID, want string) (string, error) {
const maxAttempts = 50
var cur *string
var source string
if err := db.DB.QueryRowContext(ctx,
`SELECT session_alias, COALESCE(alias_source, 'platform') FROM sessions WHERE session_id = $1`,
id).Scan(&cur, &source); err != nil {
return "", err
}
if source == "manual" && cur != nil && *cur != "" {
return *cur, nil
}
for i := 0; i < maxAttempts; i++ {
candidate := want
if i > 0 {
candidate = fmt.Sprintf("%s-%d", want, i+1)
}
owner, err := aliasOwner(ctx, candidate)
if err != nil {
return "", err
}
if owner != nil {
if *owner == id {
return candidate, nil // 已经是这个别名,无需写入
}
continue // 被别人占用,试下一个后缀
}
// 只在仍是 platform 来源时写入:并发下用户可能刚好接受了改名提议,
// 条件放进 WHERE 才能保证「检查」与「写入」不被插进来的手工改名割开
res, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET session_alias = $1, updated_at = NOW()
WHERE session_id = $2 AND COALESCE(alias_source, 'platform') <> 'manual'`,
candidate, id)
if err == nil {
if n, _ := res.RowsAffected(); n == 0 {
// 期间变成 manual 了,尊重人的选择
return SessionAliasOf(ctx, id), nil
}
return candidate, nil
}
// 并发下另一个请求刚占走该别名(唯一索引拦下),继续试下一个后缀
if db.IsUniqueViolation(err) {
continue
}
return "", err
}
return "", fmt.Errorf("alias %q: 连同 -2..-%d 后缀均被占用", want, maxAttempts)
}
// aliasOwner 返回持有该别名的会话 ID;无人持有时返回 nil。
func aliasOwner(ctx context.Context, alias string) (*uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx,
`SELECT session_id FROM sessions WHERE session_alias = $1`, alias).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &id, nil
}
func SessionMailCount(ctx context.Context, sessionID uuid.UUID) (int, error) {
var count int
err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM mails WHERE session_id = $1`, sessionID).Scan(&count)
return count, err
}
// ---------- Contacts / Archive ----------
// Contact 是「联系人」= 一条 name@path.session 三维地址
type Contact struct {
SessionID uuid.UUID `json:"session_id"`
AgentName string `json:"agent_name"`
Path string `json:"path"`
SessionAlias string `json:"session_alias"`
Address string `json:"address"` // name@path.session
Status string `json:"status"`
MailCount int `json:"mail_count"`
UnreadCount int `json:"unread_count"`
LastActivity time.Time `json:"last_activity"`
// Subject 是会话主题(多由 Agent 平台的模型生成的摘要)。
// 卡片视图要靠它回答「这条线索在干什么」—— 光有 name@path.alias
// 只能看出跟谁在聊,看不出在聊什么。
Subject string `json:"subject"`
// MaxRounds/UsedRounds 是本任务的往返预算(0 = 不限)。
// 列表上直接可见,才不用点进每条会话去查哪件事快跑满了。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
// LastFrom/LastPreview 是最后一封邮件的发件人与正文摘要,
// 卡片视图用它显示「最新进展」——列表视图只显示地址时,
// 人必须逐条点开才知道哪条有新动静。
LastFrom string `json:"last_from"`
LastPreview string `json:"last_preview"`
}
// ListContactsFor 按 (agent, path, session) 聚合出联系人清单。
// forUser 非空时只列该用户参与的会话(owner / 收发 / 抄送);空表示不限(管理员全局视图)。
// archived=false 只列活跃会话,true 只列归档会话。
func ListContactsFor(ctx context.Context, forUser string, archived bool) ([]Contact, error) {
op := "<>"
if archived {
op = "="
}
scope := ""
args := []any{}
if forUser != "" {
scope = ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
OR EXISTS (
SELECT 1 FROM mails mm
WHERE mm.session_id = s.session_id
AND (mm.from_name = $1 OR mm.to_name = $1
OR `+db.CCHas("mm.cc_list", 1)+`)
))`
args = append(args, forUser)
}
// 取会话里最早那封邮件作为联系人身份。
// PG 用 LATERAL 子查询;SQLite 无 LATERAL,改用关联子查询逐列取值
// (同一个 min(created_at) 子句,四列取自同一行)。
var firstMail string
if db.D == db.Postgres {
firstMail = `
JOIN LATERAL (
SELECT to_name, to_workspace, from_name, from_workspace
FROM mails
WHERE session_id = s.session_id
ORDER BY created_at ASC
LIMIT 1
) m ON TRUE`
} else {
firstMail = `
JOIN mails m ON m.mail_id = (
SELECT mail_id FROM mails
WHERE session_id = s.session_id
ORDER BY created_at ASC, mail_id ASC
LIMIT 1
)`
}
// 最后一封邮件用关联子查询取,不再 JOIN 一次:
// 两个 JOIN(最早一封 + 最新一封)在 SQLite 下要写两段方言分支,
// 而这里每个会话只多两次索引查找(idx_mails_session 已有)。
lastMail := `(
SELECT mail_id FROM mails
WHERE session_id = s.session_id
ORDER BY created_at DESC, mail_id DESC
LIMIT 1
)`
rows, err := db.DB.QueryContext(ctx, `
SELECT s.session_id,
COALESCE(NULLIF(m.to_name, 'human'), m.from_name) AS agent_name,
COALESCE(NULLIF(m.to_workspace, ''), m.from_workspace) AS path,
COALESCE(s.session_alias, '') AS alias,
s.status,
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id),
(SELECT COUNT(*) FROM mails x WHERE x.session_id = s.session_id AND x.status = 'unread'),
s.updated_at,
s.subject,
COALESCE(s.max_rounds, 0),
COALESCE(s.used_rounds, 0),
COALESCE((SELECT from_name FROM mails WHERE mail_id = `+lastMail+`), ''),
COALESCE((SELECT body FROM mails WHERE mail_id = `+lastMail+`), '')
FROM sessions s`+firstMail+`
WHERE s.status `+op+` 'archived'`+scope+`
ORDER BY s.updated_at DESC
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
var c Contact
if err := rows.Scan(&c.SessionID, &c.AgentName, &c.Path, &c.SessionAlias,
&c.Status, &c.MailCount, &c.UnreadCount, &c.LastActivity,
&c.Subject, &c.MaxRounds, &c.UsedRounds,
&c.LastFrom, &c.LastPreview); err != nil {
return nil, err
}
c.Address = c.AgentName + "@" + c.Path
if c.SessionAlias != "" {
c.Address += "." + c.SessionAlias
}
// 正文只留一段摘要:卡片上放不下全文,而整个列表带全文可能几百 KB。
// 按 rune 截断而非字节 —— 中文 3 字节/字,裸切会产生 U+FFFD。
c.LastPreview = previewRunes(c.LastPreview, 90)
contacts = append(contacts, c)
}
return contacts, rows.Err()
}
// previewRunes 按字符数截断,附省略号。
// 不按字节切:中文一字三字节,裸切会在末尾留半个字符(渲染成 U+FFFD)。
func previewRunes(s string, n int) string {
s = strings.TrimSpace(s)
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n]) + "…"
}
// ArchiveSession 归档一个会话(邮箱界面不再展示,数据保留)
func ArchiveSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET status = 'archived', updated_at = NOW() WHERE session_id = $1`,
sessionID)
if err != nil {
return err
}
// 同时把该会话下的邮件标记为已归档,收件箱不再列出
_, err = db.DB.ExecContext(ctx,
`UPDATE mails SET status = 'archived' WHERE session_id = $1 AND status <> 'archived'`,
sessionID)
return err
}
// FindSessionByAddress 按 name@path.session 定位会话
func FindSessionByAddress(ctx context.Context, name, path, alias string) (uuid.UUID, error) {
var id uuid.UUID
err := db.DB.QueryRowContext(ctx, `
SELECT s.session_id
FROM sessions s
JOIN mails m ON m.session_id = s.session_id
WHERE COALESCE(s.session_alias, '') = $1
AND (m.to_name = $2 OR m.from_name = $2)
AND (COALESCE(m.to_workspace,'') = $3 OR COALESCE(m.from_workspace,'') = $3)
LIMIT 1
`, alias, name, path).Scan(&id)
return id, err
}
// SuggestPaths 返回某 agent 已注册的工作区名(用于发信补全)
func SuggestPaths(ctx context.Context, agentName string) ([]string, error) {
var wsJSON []byte
err := db.DB.QueryRowContext(ctx,
`SELECT workspaces FROM agents WHERE agent_name = $1`, agentName).Scan(&wsJSON)
if err != nil {
return []string{}, err
}
var ws []models.Workspace
if len(wsJSON) > 0 {
json.Unmarshal(wsJSON, &ws)
}
out := make([]string, 0, len(ws))
for _, w := range ws {
out = append(out, w.Name)
}
return out, nil
}
// SuggestSessionsFor 返回某 (peer, path) 下当前用户可见的会话别名(用于发信补全)
func SuggestSessionsFor(ctx context.Context, forUser, peerName, path string) ([]string, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT DISTINCT s.session_alias
FROM sessions s
JOIN mails m ON m.session_id = s.session_id
WHERE s.session_alias IS NOT NULL
AND s.status <> 'archived'
AND (m.to_name = $1 OR m.from_name = $1)
AND ($2 = '' OR COALESCE(m.to_workspace,'') = $2 OR COALESCE(m.from_workspace,'') = $2)
AND ($3 = '' OR s.owner_user_id = (SELECT user_id FROM users WHERE username = $3)
OR EXISTS (
SELECT 1 FROM mails mm
WHERE mm.session_id = s.session_id
AND (mm.from_name = $3 OR mm.to_name = $3
OR `+db.CCHas("mm.cc_list", 3)+`)
))
ORDER BY s.session_alias
`, peerName, path, forUser)
if err != nil {
return []string{}, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var alias string
if err := rows.Scan(&alias); err == nil && alias != "" {
out = append(out, alias)
}
}
return out, rows.Err()
}
// ListSentBy 列出某发件人发出的邮件(发件箱),排除已归档会话
func ListSentBy(ctx context.Context, fromName string, limit int) ([]models.Mail, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT m.mail_id, m.session_id, m.parent_mail_id,
m.from_name, m.from_workspace, m.to_name, m.to_workspace,
m.cc_list, m.subject, m.body, m.mail_type, COALESCE(m.permission_result,'') AS permission_result,
m.status, m.created_at, s.session_alias
FROM mails m
JOIN sessions s ON m.session_id = s.session_id
WHERE m.from_name = $1 AND s.status <> 'archived'
ORDER BY m.created_at DESC, m.mail_id DESC
LIMIT $2
`, fromName, limit)
if err != nil {
return nil, err
}
defer rows.Close()
mails := []models.Mail{}
for rows.Next() {
var m models.Mail
var alias *string
var ccJSON []byte
if err := rows.Scan(&m.ID, &m.SessionID, &m.ParentMailID,
&m.FromName, &m.FromWorkspace, &m.ToName, &m.ToWorkspace,
&ccJSON, &m.Subject, &m.Body, &m.MailType, &m.PermResult,
&m.Status, &m.CreatedAt, &alias); err != nil {
return nil, err
}
if len(ccJSON) > 0 {
json.Unmarshal(ccJSON, &m.CCList)
}
if m.CCList == nil {
m.CCList = []models.Address{}
}
if alias != nil {
m.SessionAlias = *alias
}
if len(m.Body) > 200 {
m.BodyPreview = m.Body[:200] + "..."
} else {
m.BodyPreview = m.Body
}
mails = append(mails, m)
}
return mails, rows.Err()
}
// ListPendingPermissionsFor 列出待决权限请求;forUser 非空时只列发给该用户的
func ListPendingPermissionsFor(ctx context.Context, forUser string) ([]models.PermissionRequest, error) {
q := `SELECT pr.request_id, pr.mail_id, pr.session_id, pr.agent_name, pr.question,
pr.options, pr.context, pr.result, pr.decided_at, pr.created_at
FROM permission_requests pr
JOIN mails m ON m.mail_id = pr.mail_id
JOIN sessions s ON s.session_id = pr.session_id
WHERE pr.result IS NULL AND s.status <> 'archived'`
args := []any{}
if forUser != "" {
q += ` AND m.to_name = $1`
args = append(args, forUser)
}
q += ` ORDER BY pr.created_at DESC`
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
reqs := []models.PermissionRequest{}
for rows.Next() {
var pr models.PermissionRequest
var optsJSON []byte
if err := rows.Scan(&pr.ID, &pr.MailID, &pr.SessionID, &pr.AgentName, &pr.Question,
&optsJSON, &pr.Context, &pr.Result, &pr.DecidedAt, &pr.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal(optsJSON, &pr.Options)
reqs = append(reqs, pr)
}
return reqs, nil
}
// ListSessionsFor 列出某人类用户参与的会话(owner / 收发 / 抄送);forUser 空表示不限
func ListSessionsFor(ctx context.Context, forUser string, limit int) ([]models.Session, error) {
q := `SELECT s.session_id, s.session_alias, s.from_agent, s.subject, s.status,
s.owner_user_id, s.created_at, s.updated_at,
COALESCE(s.max_rounds, 0), COALESCE(s.used_rounds, 0),
(SELECT COUNT(*) FROM mails m WHERE m.session_id = s.session_id)
FROM sessions s
WHERE s.status <> 'archived'`
args := []any{}
if forUser != "" {
q += ` AND (s.owner_user_id = (SELECT user_id FROM users WHERE username = $1)
OR EXISTS (
SELECT 1 FROM mails mm
WHERE mm.session_id = s.session_id
AND (mm.from_name = $1 OR mm.to_name = $1
OR `+db.CCHas("mm.cc_list", 1)+`)
))`
args = append(args, forUser)
}
q += ` ORDER BY s.updated_at DESC`
if limit > 0 {
q += fmt.Sprintf(` LIMIT %d`, limit)
}
rows, err := db.DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
sessions := []models.Session{}
for rows.Next() {
var s models.Session
// 列数必须与上面的 SELECT 一一对应 —— 预算两列曾经只加进了查询而没加进这里,
// 结果 /me/sessions 整个 500,联系人栅拉不到任何数据。
if err := rows.Scan(&s.ID, &s.Alias, &s.FromAgent, &s.Subject, &s.Status,
&s.OwnerUserID, &s.CreatedAt, &s.UpdatedAt,
&s.MaxRounds, &s.UsedRounds, &s.MailCount); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, rows.Err()
}
// CountUnreadInSession 统计某人在某会话内的未读数(含被抄送)
func CountUnreadInSession(ctx context.Context, name string, sessionID uuid.UUID) (int, error) {
ccProbe, _ := json.Marshal([]map[string]string{{"name": name}})
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM mails
WHERE session_id = $1
AND status = 'unread'
AND (to_name = $2 OR cc_list @> $3::jsonb)
`, sessionID, name, string(ccProbe)).Scan(&n)
return n, err
}
// SetMailRenameProposal 记录某封邮件里 Agent 提议的新会话别名。
//
// 单独一条 UPDATE 而不是塞进 CreateMail 的参数表:提议是可选的旁支信息,
// 让三个调用点都多传两个几乎总是空串的参数不值当。
func SetMailRenameProposal(ctx context.Context, mailID uuid.UUID, alias, reason string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE mails SET rename_alias = $1, rename_reason = $2 WHERE mail_id = $3`,
alias, reason, mailID)
return err
}
// PendingRenameProposal 返回某会话里**最新一条尚未处理**的改名提议。
//
// 「尚未处理」= 提议的别名既不是当前别名(已接受),也不在驳回记录里。
// 无提议时返回 ("", "", nil)。
//
// 只看最新一条:Agent 干活过程中可能多次提议,最后那条才是它现在的结论。
func PendingRenameProposal(ctx context.Context, sessionID uuid.UUID) (alias, reason string, err error) {
var cur, dismissed *string
err = db.DB.QueryRowContext(ctx,
`SELECT session_alias, rename_dismissed FROM sessions WHERE session_id = $1`,
sessionID).Scan(&cur, &dismissed)
if err != nil {
return "", "", err
}
var a, rs *string
err = db.DB.QueryRowContext(ctx, `
SELECT rename_alias, rename_reason FROM mails
WHERE session_id = $1 AND rename_alias IS NOT NULL AND rename_alias <> ''
ORDER BY created_at DESC, mail_id DESC
LIMIT 1
`, sessionID).Scan(&a, &rs)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", err
}
if a == nil || *a == "" {
return "", "", nil
}
// 已经改成这个名字了 = 提议已被接受,不必再提示
if cur != nil && *cur == *a {
return "", "", nil
}
// 用户驳回过这个建议
if dismissed != nil && *dismissed == *a {
return "", "", nil
}
if rs != nil {
reason = *rs
}
return *a, reason, nil
}
// DismissRenameProposal 记下用户驳回了哪个建议,好让提示条不再反复弹。
//
// 只存最后驳回的那一个而不是一张列表:Agent 每次提的名字都不同,
// 攒一张历史表除了占地方没有别的用处 —— 需要判断的只是「当前这条提议是否被否过」。
func DismissRenameProposal(ctx context.Context, sessionID uuid.UUID, alias string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET rename_dismissed = $1 WHERE session_id = $2`,
alias, sessionID)
return err
}
// MarkMailsReadFor 把一批邮件标记为某收件人已读,返回实际影响的行数。
//
// **鉴权写进 WHERE 而不是先查后改**:`to_name = $1 OR cc 含 $1` 直接放在
// UPDATE 条件里,于是「不是发给我的邮件」根本改不动 —— 既省掉一次查询,
// 也没有「查完到改之间邮件被转走」的时间窗。
//
// 已经是 read 的不计入影响行数(`status = 'unread'` 条件),
// 调用方据此知道这次真正标掉了几封。
func MarkMailsReadFor(ctx context.Context, recipient string, ids []uuid.UUID) (int, error) {
if len(ids) == 0 {
return 0, nil
}
// IN 子句的占位符按方言编号(SQLite 与 PG 都认 $N)。
// 不用一条条 UPDATE:一次网络往返 + 一次事务,SQLite 单写者下差别明显。
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, recipient)
for i, id := range ids {
ph[i] = fmt.Sprintf("$%d", i+2)
args = append(args, id)
}
res, err := db.DB.ExecContext(ctx, `
UPDATE mails SET status = 'read'
WHERE mail_id IN (`+strings.Join(ph, ",")+`)
AND status = 'unread'
AND (to_name = $1 OR `+db.CCHas("cc_list", 1)+`)
`, args...)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// MarkAllInboxReadFor 把某收件人收件箱里全部未读标为已读,返回影响行数。
//
// 排除已归档会话:那些邮件在收件箱里根本看不到,
// 标掉它们只会让「标记了 N 封」这个数字与用户看到的对不上。
func MarkAllInboxReadFor(ctx context.Context, recipient string) (int, error) {
res, err := db.DB.ExecContext(ctx, `
UPDATE mails SET status = 'read'
WHERE status = 'unread'
AND (to_name = $1 OR `+db.CCHas("cc_list", 1)+`)
AND session_id IN (SELECT session_id FROM sessions WHERE status <> 'archived')
`, recipient)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}