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 例模板渲染。
生产端到端验证并清理了数据。
This commit is contained in:
2026-09-04 06:28:03 +08:00
parent 4d211c84d6
commit 069bf03ae2
10 changed files with 3495 additions and 0 deletions

View File

@ -0,0 +1,384 @@
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
}
}

View File

@ -0,0 +1,111 @@
package scheduler
import (
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
)
func TestRenderReminder(t *testing.T) {
at := time.Date(2026, 9, 4, 9, 30, 0, 0, time.Local)
e := models.CalendarEvent{
Title: "每日站会",
Description: "同步昨天进展与今天计划",
EventTime: at,
}
t.Run("三个变量都替换", func(t *testing.T) {
got := RenderReminder("日程提醒:{title}\n时间{time}\n{description}", e)
for _, want := range []string{"每日站会", "2026-09-04 09:30", "同步昨天进展与今天计划"} {
if !strings.Contains(got, want) {
t.Errorf("渲染结果缺 %q\n%s", want, got)
}
}
if strings.Contains(got, "{") {
t.Errorf("仍有未替换的占位符:\n%s", got)
}
})
t.Run("同一变量出现多次全部替换", func(t *testing.T) {
// ReplaceAll 而非 Replace模板里写两遍 {title} 时
// 只替换第一处会让 Agent 收到一封半成品邮件。
got := RenderReminder("{title} —— 请开始 {title}", e)
if strings.Contains(got, "{title}") {
t.Errorf("第二处 {title} 未替换:%s", got)
}
})
t.Run("空模板不产生占位符残留", func(t *testing.T) {
if got := RenderReminder("", e); got != "" {
t.Errorf("空模板应渲染成空串,得到 %q", got)
}
})
t.Run("没有变量的模板原样返回", func(t *testing.T) {
const plain = "该跑测试了"
if got := RenderReminder(plain, e); got != plain {
t.Errorf("纯文本模板应原样返回,得到 %q", got)
}
})
t.Run("描述为空时不留下空行以外的痕迹", func(t *testing.T) {
e2 := e
e2.Description = ""
got := RenderReminder("{title}|{description}|", e2)
if got != "每日站会||" {
t.Errorf("空描述应替换成空串,得到 %q", got)
}
})
// {time} 必须是本地时间。DSN 带 _timezone=UTC从库里读回的 EventTime
// 是 UTC不转本地就会把人在 +0800 输入的 14:30 写成 06:30
// 而前端预览用的是本地时间 —— 两边差 8 小时且都不报错。
t.Run("time 用本地时区而非 UTC", func(t *testing.T) {
// 刻意构造一个 UTC 时刻(模拟从库里 Scan 出来的样子)
utcEvent := models.CalendarEvent{
Title: "跨时区检查",
EventTime: time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC),
}
got := RenderReminder("{time}", utcEvent)
want := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC).Local().Format("2006-01-02 15:04")
if got != want {
t.Errorf("{time} = %q期望本地时间 %q", got, want)
}
})
// 同一时刻无论以哪个时区的 Location 传进来,渲染结果必须一致 ——
// 它代表的是「墙上时钟的那一刻」,与 Location 的表示方式无关。
t.Run("同一时刻不同 Location 渲染一致", func(t *testing.T) {
base := time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)
a := RenderReminder("{time}", models.CalendarEvent{EventTime: base})
b := RenderReminder("{time}", models.CalendarEvent{EventTime: base.Local()})
if a != b {
t.Errorf("UTC 与 Local 表示同一时刻却渲染出不同结果:%q vs %q", a, b)
}
})
}
func TestShort(t *testing.T) {
// 日志里截前 8 位;短 id测试里可能出现不能 panic
if got := short("0123456789abcdef"); got != "01234567" {
t.Errorf("长 id 应截断成 8 位,得到 %q", got)
}
if got := short("abc"); got != "abc" {
t.Errorf("短 id 应原样返回,得到 %q", got)
}
if got := short(""); got != "" {
t.Errorf("空串应原样返回,得到 %q", got)
}
}
func TestStartStopIdempotent(t *testing.T) {
// Stop 在没启动时被调defer 里必然发生)不该 panic
// Start 两次也不该泄漏 goroutine第二次先停旧的
Stop()
Start()
Start()
Stop()
Stop()
}