Files
MailUI4Agents/gateway/internal/scheduler/calendar.go
JianFeeeee 069bf03ae2 feat(calendar): 日历后端 —— 事件/提醒/重复规则 + iCal + 多收件人
三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。

日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。

**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together        = 首个为主收件人、其余进 cc_list、共享一条线索

「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。

抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。

recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。

---

修掉的七个真问题:

**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。

**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。

**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。

**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。

**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。

**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。

**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。

---

其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
  在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
  时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
  「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
  它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
  兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
  无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
  不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)

测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
2026-09-04 06:28:03 +08:00

385 lines
14 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 scheduler
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/sse"
"github.com/google/uuid"
)
// CalendarScheduler 定时扫描日历事件,触发到期提醒。
//
// 设计参照 Outlook 的 Exchange 提醒器:
// - 每 30 秒扫描一次(精度到分钟够用,不需要秒级)
// - 到期事件 → 生成一封提醒邮件 → 投递
// - 重复事件自动推进到下一次
// - 幂等last_fired_at 保证同一分钟不触发两次
//
// 为什么用进程内 goroutine 而不是 cron调度器与 Gateway 同生命周期,
// 不需要外部依赖,也不需要第二套「谁在跑」的信任模型。
var (
schedMu sync.Mutex
schedStop chan struct{}
schedWg sync.WaitGroup
)
// Start 启动日历调度器。重复调用安全(先停旧的)。
func Start() {
Stop()
schedMu.Lock()
defer schedMu.Unlock()
schedStop = make(chan struct{})
stop := schedStop
schedWg.Add(1)
go func() {
defer schedWg.Done()
runLoop(stop)
}()
log.Printf("[scheduler] 日历调度器已启动30s 周期)")
}
// Stop 停止调度器,等待当前扫描完成。
func Stop() {
schedMu.Lock()
defer schedMu.Unlock()
if schedStop != nil {
close(schedStop)
schedWg.Wait()
schedStop = nil
log.Printf("[scheduler] 日历调度器已停止")
}
}
func runLoop(stop chan struct{}) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// 启动时先扫一次:进程重启期间到期的事件不该被跳过
scanAndFire()
for {
select {
case <-stop:
return
case <-ticker.C:
scanAndFire()
}
}
}
// scanAndFire 扫一轮到期事件。
//
// **全程不得 panic 出去**:它跑在后台 goroutine 里,而 Go 的 goroutine panic
// 会直接结束整个进程 —— 一条写坏的提醒把邮件网关整个带倒是荒谬的代价。
func scanAndFire() {
defer func() {
if rec := recover(); rec != nil {
log.Printf("[scheduler] 扫描崩溃(已捕获,下一轮重试): %v", rec)
}
}()
// DB 未就绪就什么都不做。
//
// 调度器的启动时机比它看起来脆弱main 里它排在 migrate 之后,
// 但 Start() 是个导出函数,测试与将来的调用方都可能在建库前调到它。
// 而 nil *sql.DB 上调 QueryContext 是空指针解引用,不是一个 error。
if db.DB == nil {
return
}
ctx := context.Background()
events, err := repo.DueEvents(ctx)
if err != nil {
log.Printf("[scheduler] 查询到期事件失败: %v", err)
return
}
for _, e := range events {
fireEvent(ctx, e)
}
}
// RenderReminder 把提醒模板里的变量替换成事件的实际内容。
//
// 单独提出来是为了可测:模板渲染错了不会立刻报错,
// 只会让 Agent 收到一封写着 `{title}` 的邮件。
//
// **{time} 必须转本地时区再格式化。**
// DSN 带 `_timezone=UTC`,从库里读回的 EventTime 是 UTC直接 Format
// 会把人在 +0800 输入的 14:30 写成 06:30而前端预览用的是本地时间
// —— 于是预览显示 14:30、Agent 收到 06:30两边差 8 小时且两边都不报错。
// 日历是给人看的,人说「下午两点半」指的就是自己时区的那个时刻。
func RenderReminder(tmpl string, e models.CalendarEvent) string {
body := tmpl
body = strings.ReplaceAll(body, "{title}", e.Title)
body = strings.ReplaceAll(body, "{time}", e.EventTime.Local().Format("2006-01-02 15:04"))
body = strings.ReplaceAll(body, "{description}", e.Description)
return body
}
// fireEvent 触发一条事件:渲染提醒文本、投递邮件、推进重复。
//
// 顺序很重要:**先标记已触发再发信**。
// 反过来的话,发信成功但标记失败会让下一轮再发一遍 ——
// 提醒邮件重复比漏发更糟Agent 会把同一件事做两次)。
func fireEvent(ctx context.Context, e models.CalendarEvent) {
// 单条事件崩溃不得让同一轮里剩下的提醒全部落空。
defer func() {
if rec := recover(); rec != nil {
log.Printf("[scheduler] 事件 %s 触发崩溃(已捕获): %v", short(e.EventID), rec)
}
}()
// 收件人Recipients 优先,为空时退回 to_address / agent_name旧数据
recipients := e.EffectiveRecipients()
if len(recipients) == 0 {
log.Printf("[scheduler] 事件 %s 没有收件人跳过title=%q", short(e.EventID), e.Title)
// 没有收件人的事件永远发不出去,标记已触发免得每 30 秒重试一次
_ = repo.MarkEventFired(ctx, e.EventID)
return
}
if err := repo.MarkEventFired(ctx, e.EventID); err != nil {
log.Printf("[scheduler] 标记事件 %s 已触发失败,本轮不发信: %v", short(e.EventID), err)
return
}
body := RenderReminder(e.ReminderText, e)
subject := "日程提醒:" + e.Title
switch e.EffectiveDeliveryMode() {
case models.DeliverTogether:
// 一起发:首个是主收件人,其余进 cc_list —— 所有人共享同一条线索,
// 能看到彼此的回复。适合「pi 主办、dsh 知情」这种有主次的协作。
primary, cc := recipients[0], recipients[1:]
if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, cc...); err != nil {
log.Printf("[scheduler] 投递提醒失败(%s → %s +%d抄送: %v",
e.Title, primary, len(cc), err)
} else {
log.Printf("[scheduler] 已触发日历提醒: %s → %s抄送 %d 人,同一线索)",
e.Title, primary, len(cc))
}
default:
// 各发一封:每人落在自己的会话里,互相看不到。
// 适合「让三个 Agent 各自独立汇报」—— 用 together 会让他们互相
// 看到回复而趋同,那种上下文污染事后无法分离。
//
// 一个失败不影响其余:三个 Agent 里有一个离线时,
// 另外两个仍该收到提醒。
ok, failed := 0, 0
for _, addr := range recipients {
if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy); err != nil {
failed++
log.Printf("[scheduler] 投递提醒失败(%s → %s: %v", e.Title, addr, err)
continue
}
ok++
}
if failed == 0 {
log.Printf("[scheduler] 已触发日历提醒: %s → %d 个收件人(各自独立会话)",
e.Title, ok)
} else {
log.Printf("[scheduler] 日历提醒 %s%d 成功 / %d 失败", e.Title, ok, failed)
}
}
// 重复事件推进到下一次;一次性事件到此结束
if e.Recurrence != models.RecurNone {
advanced, err := repo.AdvanceRecurrence(ctx, e.EventID)
if err != nil {
log.Printf("[scheduler] 推进重复事件 %s 失败: %v", short(e.EventID), err)
} else if !advanced {
log.Printf("[scheduler] 重复事件 %s 已过 recurrence_end 或无法推进,已置为 cancelled",
short(e.EventID))
}
}
}
func short(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
// SendCalendarMail 把一条提醒投成邮件。
//
// 直接走 repo + sse 而不是自己发一个 HTTP 请求到 /mail/send
// 调度器是进程内 goroutine绕出去再进来只是多一次鉴权与序列化。
//
// **日历提醒不扣会话预算**:预算的语义是「这件事值得模型自主发多少封信」,
// 而提醒是人预先设定的定时任务,不是模型的自主行为。让它扣预算会出现
// 「每天 9 点的日报提醒把当天的预算吃掉一格」这种反直觉结果。
//
// 收件地址支持完整三维寻址:
// - `agent` → 该 Agent 的默认会话(长期提醒应该用这个,
// 所有提醒落在同一条线索上,模型看得到历史)
// - `agent@/path` → 指定工作目录的默认会话
// - `agent@/path.alias` → 指定已存在的会话(不存在则报错,不静默新建)
// - `agent@/path.new` → 每次提醒开一条新会话(适合互不相关的一次性任务)
func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy string, ccAddrs ...string) error {
addr, err := models.ParseAddress(toAddr)
if err != nil {
return fmt.Errorf("收件地址 %q 无法解析: %w", toAddr, err)
}
// 抄送方逐个解析。单个解析失败只跳过它,不让整封信发不出去 ——
// 主收件人能收到提醒比「抄送名单必须完整」重要。
ccList := make([]models.Address, 0, len(ccAddrs))
for _, raw := range ccAddrs {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
ca, cErr := models.ParseAddress(raw)
if cErr != nil {
log.Printf("[scheduler] 抄送地址 %q 无法解析,已跳过: %v", raw, cErr)
continue
}
// 抄送方的 session 位不参与会话定位(那是主收件人的事),
// 但必须保留在地址里cc_list 的 name/path 决定谁能看到这条线索。
ccList = append(ccList, ca)
}
sessionID, err := resolveCalendarSession(ctx, addr, subject)
if err != nil {
return err
}
// 发件人固定为 calendar它不是任何一个 Agent也不是人。
// 用创建者的名字会让 Agent 以为人在实时找它,而人此刻可能在睡觉 ——
// 模型据此判断「要不要马上追问」,来源写错会让它问一个不在线的人。
mailID, err := repo.CreateMail(ctx, sessionID, nil,
calendarSender, "", addr.Name, addr.Path, subject, body, ccList)
if err != nil {
return err
}
// 事件附件复制成邮件附件。失败不阻断投递:提醒本身(正文)比附件重要得多,
// 少一个附件也比整条提醒发不出去好 —— 后者会让人以为定时任务坏了。
if eventID != "" {
if n, aErr := repo.AttachCalendarFilesToMail(ctx, eventID, mailID, calendarSender); aErr != nil {
log.Printf("[scheduler] 事件 %s 的附件挂载失败(提醒仍已发出): %v", short(eventID), aErr)
} else if n > 0 {
log.Printf("[scheduler] 事件 %s 随提醒带了 %d 个附件", short(eventID), n)
}
}
alias := repo.SessionAliasOf(ctx, sessionID)
sse.Default.SendToRecipient(addr.Name, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": calendarSender,
"subject": subject,
"mail_type": "normal",
"role": "to",
"to_workspace": addr.Path,
"session_alias": alias,
// 回信地址给 calendar 是发不出去的(它不是收件方),
// 给会话自己的地址才让模型能把结果回报到同一条线索上。
"reply_address": models.FormatAddress(addr.Name, addr.Path, alias),
"self_address": models.FormatAddress(addr.Name, addr.Path, alias),
// 让插件与 UI 能区分「这封是定时提醒」而不是有人在找它
"origin": "calendar",
})
sse.Default.SendToRecipient(addr.Name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
// 抄送方也要收到 SSE。
//
// 漏了这一步的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱
// 能看到这封信,但没有任何事件推给他们 —— 于是插件不会唤起会话,
// Agent 直到下一次补拉(重启时)才发现。对「知情方」而言等于没通知。
for _, c := range ccList {
alias := repo.SessionAliasOf(ctx, sessionID)
sse.Default.SendToRecipient(c.Name, "new_mail", map[string]interface{}{
"mail_id": mailID.String(),
"session_id": sessionID.String(),
"from_name": calendarSender,
"subject": subject,
"mail_type": "normal",
"role": "cc",
"to_workspace": c.Path,
"session_alias": alias,
"reply_address": models.FormatAddress(addr.Name, addr.Path, alias),
"self_address": models.FormatAddress(c.Name, c.Path, alias),
"origin": "calendar",
})
sse.Default.SendToRecipient(c.Name, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}
// 创建者也该看到提醒发出去了 —— 否则「我设的提醒到底触发了没有」
// 只能去翻 journalctl。
if createdBy != "" && createdBy != addr.Name {
sse.Default.SendToRecipient(createdBy, "session_update", map[string]interface{}{
"session_id": sessionID.String(),
"status": "active",
})
}
return nil
}
// calendarSender 是提醒邮件的发件人名。
//
// 刻意不是人类用户名也不是 Agent 名:这样收件方一眼能看出这封信来自定时任务,
// 而 `from_name` 又不会与命名空间里任何真实账号冲突。
const calendarSender = "calendar"
// resolveCalendarSession 按地址的 session 位定位会话。
//
// 与 handler.resolveTarget 同一套三态语义,但**不受新建会话速率限制**
// 那条限制是防 Agent 暴开线索的,而日历事件的数量由人在界面上决定。
func resolveCalendarSession(ctx context.Context, addr models.Address, subject string) (uuid.UUID, error) {
switch addr.Mode() {
case models.SessionNew:
id, err := repo.CreateSession(ctx, nil, calendarSender, subject, addr.Path)
if err != nil {
return uuid.Nil, err
}
// 与发信路径一致:`.new` 建完必须立刻有别名,否则这条会话
// 除了回复那一封之外再也无法寻址(未命名会话查不到也补全不出来)。
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
return id, nil
case models.SessionNamed:
id, err := repo.FindNamedSessionFor(ctx, addr.Name, addr.Path, addr.Session)
if errors.Is(err, repo.ErrSessionNotFound) {
// 不静默新建:别名指向不存在的会话时报错,与人发信时的语义一致。
// 静默新建会让「提醒发到哪去了」变成一个查不清的问题。
return uuid.Nil, fmt.Errorf("会话 %q 不存在于 %s@%s", addr.Session, addr.Name, addr.Path)
}
if err != nil {
return uuid.Nil, err
}
repo.TouchSession(ctx, id)
return id, nil
default: // SessionDefault
id, err := repo.FindOrCreateDefaultSession(ctx, addr.Name, addr.Path, calendarSender, subject)
if err != nil {
return uuid.Nil, err
}
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
return id, nil
}
}