@ -64,7 +64,7 @@ type sendMailRequest struct {
// 人类不受此限(手工点「新建邮件」的频率天然受限,加限制只会在批量派活时误伤)。
// resolveTarget 依据地址的 session 位定位(或新建)会话。
//
// 返回值:会话 id / 父邮件 id( 仅 reply_to 路径非 nil) / **created** / 错误。
// 返回值:会话 id / 父邮件 id( 仅 reply_to 路径非 nil) / 父邮件发件人(仅 reply_to 路径非空,其余路径空串)/ **created** / 错误。
//
// created 为真**仅**表示这次调用真的新建了一条会话。它存在的理由是:
// `parentMailID == nil` 曾被当作「新建会话」的判据,而那是错的 ——
@ -72,15 +72,15 @@ type sendMailRequest struct {
// 第一封信 `max_rounds=7`,第二封信省略该字段,会话预算被静默改成 20。
// 「只在新建时生效」的字段(往返预算、权限档位)必须靠这个返回值判断,
// 否则每封新信都在改写对方正在遵守的规则。
func resolveTarget ( r * http . Request , addr models . Address , replyTo , fromAgent , subject , alias string , byAgent string ) ( uuid . UUID , * uuid . UUID , bool , error ) {
func resolveTarget ( r * http . Request , addr models . Address , replyTo , fromAgent , subject , alias string , byAgent string ) ( uuid . UUID , * uuid . UUID , string , bool , error ) {
if replyTo != "" {
replyID , err := uuid . Parse ( replyTo )
if err != nil {
return uuid . Nil , nil , false , errBadRequest ( "Invalid reply_to UUID" )
return uuid . Nil , nil , "" , false , errBadRequest ( "Invalid reply_to UUID" )
}
mail , err := repo . GetMailByID ( r . Context ( ) , replyID )
if err != nil {
return uuid . Nil , nil , false , errNotFound ( "Parent mail not found" )
return uuid . Nil , nil , "" , false , errNotFound ( "Parent mail not found" )
}
// 归档契约在这条路径上同样成立:别名寻址回 404( FindNamedSessionFor
// 带 s.status <> 'archived') , reply_to 是**绕过它的那条路**。
@ -90,11 +90,15 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
// ( ListInbox 按会话状态把它放回来、unreadFor 按邮件行继续藏)。
// 判据见 repo.TouchSession 与 session_status_invariant_test.go。
if err := repo . EnsureSessionOpen ( r . Context ( ) , mail . SessionID ) ; err != nil {
return uuid . Nil , nil , false , errNotFound (
return uuid . Nil , nil , "" , false , errNotFound (
"无法送达:被回复的邮件属于一条已归档的会话。归档是单向的,请用 .new 另起一条" )
}
repo . TouchSession ( r . Context ( ) , mail . SessionID )
return mail . SessionID , & replyID , false , nil
// ★ 把父邮件发件人一并带出去( 2026-09-30, dsh) :
// 这一行在上一个版本里只用了 SessionID 就把整行丢掉 —— 而
// notify 载荷的方向判据( in_reply_to_ignores_direction 那笔债)
// 恰恰需要它,且**数据本来就在手**,不需要任何额外查询。
return mail . SessionID , & replyID , mail . FromName , false , nil
}
switch addr . Mode ( ) {
@ -104,17 +108,17 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
var aliasPtr * string
if a := strings . TrimSpace ( alias ) ; a != "" {
if err := validateSessionAlias ( a ) ; err != nil {
return uuid . Nil , nil , false , err
return uuid . Nil , nil , "" , false , err
}
if _ , err := repo . FindSessionByAlias ( r . Context ( ) , a ) ; err == nil {
return uuid . Nil , nil , false , errConflict ( fmt . Sprintf (
return uuid . Nil , nil , "" , false , errConflict ( fmt . Sprintf (
"会话别名 %q 已被占用;若要接着该会话谈请用 %s@%s.%s" , a , addr . Name , addr . Path , a ) )
}
aliasPtr = & a
}
// Agent 主动开新线索要过速率限制
if ok , retry := repo . AllowNewSession ( r . Context ( ) , byAgent ) ; ! ok {
return uuid . Nil , nil , false , errRateLimited ( fmt . Sprintf (
return uuid . Nil , nil , "" , false , errRateLimited ( fmt . Sprintf (
"新建会话过于频繁( 1 小时内已开 %d 条)。请在已有会话里继续,或 %d 秒后再试。" ,
repo . SessionRateLimit ( ) , retry ) )
}
@ -124,7 +128,7 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
if err != nil {
// 建失败要把名额还回去:那次新建实际上没有发生
repo . ReleaseNewSession ( r . Context ( ) , byAgent )
return id , nil , false , err
return id , nil , "" , false , err
}
// `.new` 是一次性动作:它建完会话就用完了,之后要再投进这条会话只能靠
// `name@path.<别名>`。未命名会话既查不到( FindNamedSessionFor 的
@ -136,7 +140,7 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
// 只能用 reply_to 续谈,比整封退回轻。
_ , _ = repo . EnsureSessionAlias ( r . Context ( ) , id , repo . AutoAliasFor ( addr . Name , subject ) )
}
return id , nil , true , nil
return id , nil , "" , true , nil
case models . SessionDefault :
// 默认会话「从未通信则建立」也会产生新会话,但一个 name@path 只有一条,
@ -146,29 +150,29 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
// parentMailID == nil, 靠它判断会把续谈误当新建( 预算与档位被静默改写) 。
id , created , err := repo . FindOrCreateDefaultSessionCreated ( r . Context ( ) , addr . Name , addr . Path , fromAgent , subject )
if err != nil {
return id , nil , false , err
return id , nil , "" , false , err
}
// 默认会话同样需要可寻址的别名:省略 session 位能投进来,但要**指名**
// 投进这一条(而不是「该 name@path 当前的默认会话」)仍然只能靠别名。
// 已有别名时 EnsureSessionAlias 直接返回,复用旧会话不会被改名。
_ , _ = repo . EnsureSessionAlias ( r . Context ( ) , id , repo . AutoAliasFor ( addr . Name , subject ) )
return id , nil , created , nil
return id , nil , "" , created , nil
default : // models.SessionNamed
id , err := repo . FindNamedSessionFor ( r . Context ( ) , addr . Name , addr . Path , addr . Session )
if err == nil {
repo . TouchSession ( r . Context ( ) , id )
return id , nil , false , nil
return id , nil , "" , false , nil
}
// 同名别名在多个工作目录下都存在、而地址里又没写 path: 不能猜一条,
// 要让发信方补 path( 见 repo.ErrSessionAmbiguous 的注释)。
if errors . Is ( err , repo . ErrSessionAmbiguous ) {
return uuid . Nil , nil , false , errBadRequest ( fmt . Sprintf (
return uuid . Nil , nil , "" , false , errBadRequest ( fmt . Sprintf (
"别名 %q 在多个工作目录下都存在,无法确定是哪一条:请在地址里写明 path( name@/路径.%s) 。" ,
addr . Session , addr . Session ) )
}
if ! errors . Is ( err , repo . ErrSessionNotFound ) {
return uuid . Nil , nil , false , err
return uuid . Nil , nil , "" , false , err
}
// 本侧没有这条别名 —— 再看平台会话镜像。
@ -182,12 +186,12 @@ func resolveTarget(r *http.Request, addr models.Address, replyTo, fromAgent, sub
// 接管**是**新建本侧会话(绑定了 platform_id 的那条),
// 所以 created 为真:它此前没有档位与预算,需要按这次投递定下来。
if adopted , aErr := adoptFromPlatform ( r , addr , fromAgent , subject , byAgent ) ; aErr == nil {
return adopted , nil , true , nil
return adopted , nil , "" , true , nil
} else if ! errors . Is ( aErr , repo . ErrSessionNotFound ) {
return uuid . Nil , nil , false , aErr
return uuid . Nil , nil , "" , false , aErr
}
return uuid . Nil , nil , false , errNotFound ( fmt . Sprintf (
return uuid . Nil , nil , "" , false , errNotFound ( fmt . Sprintf (
"无法送达:会话 %q 不存在于 %s@%s。若要新建会话请用 %s@%s.new, 投递默认会话请省略 session 位" ,
addr . Session , addr . Name , addr . Path , addr . Name , addr . Path ) )
}
@ -294,7 +298,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
return
}
sessionID , parentMailID , created , err := resolveTarget ( r , to , req . ReplyTo , agentName , req . Subject , req . SessionAlias , agentName )
sessionID , parentMailID , parentFrom , created , err := resolveTarget ( r , to , req . ReplyTo , agentName , req . Subject , req . SessionAlias , agentName )
if err != nil {
writeErr ( w , err , "Failed to resolve session" )
return
@ -577,7 +581,7 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
_ = repo . BindRelayMail ( r . Context ( ) , agentName , relayKey , mailID )
}
notifyRecipients ( r . Context ( ) , to , ccList , sessionID , mailID , agentName , req . Subject , parentIDString ( parentMailID ) )
notifyRecipients ( r . Context ( ) , to , ccList , sessionID , mailID , agentName , req . Subject , parentIDString ( parentMailID ) , parentFrom )
// 回传会话别名与本任务剩余往返,让发件方知道后续用什么地址续谈、还能发几封
resp := map [ string ] any {
@ -610,7 +614,12 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
// 加字段时漏改一处直接造成生产事故(详见那个包的注释)。
//
// parentMailID 为空字串表示这不是回信。
func notifyRecipients ( ctx context . Context , to models . Address , cc [ ] models . Address , sessionID , mailID uuid . UUID , from , subject , parentMailID string ) {
// parentFrom 是**父邮件的发件人**(仅 reply_to 路径非空):
// 载荷带上它,插件才能区分「父邮件是我发的」(= 我的回复到了)
// 与「父邮件是别人发的」(= 第三方在续谈)—— 单向续信链同样满足
// 「有父邮件」,没有方向判据就会被读成「对方在回我」
// ( docs/DEBTS.json: in-reply-to-ignores-direction, 生产兜现过) 。
func notifyRecipients ( ctx context . Context , to models . Address , cc [ ] models . Address , sessionID , mailID uuid . UUID , from , subject , parentMailID , parentFrom string ) {
notify . Recipients ( ctx , notify . Mail {
SessionID : sessionID ,
MailID : mailID ,
@ -619,6 +628,7 @@ func notifyRecipients(ctx context.Context, to models.Address, cc []models.Addres
CC : cc ,
Subject : subject ,
ParentMailID : parentMailID ,
ParentFrom : parentFrom ,
} )
}