Files
MailUI4Agents/gateway/internal/handler/ics_test.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

424 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 handler
import (
"fmt"
"strings"
"testing"
"time"
"github.com/agentmail/gateway/internal/models"
)
// parseICS 是导入的唯一入口,解析错了不会报错 —— 事件只是安静地不出现,
// 或者出现在错误的时间。这些测试锁住 iCal 的形态约定。
func TestParseICSBasicEvent(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"BEGIN:VEVENT\r\n" +
"SUMMARY:发布评审\r\n" +
"DESCRIPTION:看 llmsproxy 的部署脚本\r\n" +
"DTSTART:20260903T063000Z\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatalf("期望 1 个事件,得到 %d", len(events))
}
e := events[0]
if e.Title != "发布评审" {
t.Errorf("Title = %q期望 发布评审", e.Title)
}
if e.Description != "看 llmsproxy 的部署脚本" {
t.Errorf("Description = %q", e.Description)
}
if !e.EventTime.Equal(time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)) {
t.Errorf("EventTime = %v期望 2026-09-03T06:30:00Z", e.EventTime)
}
if e.Recurrence != "none" {
t.Errorf("Recurrence = %q期望 none", e.Recurrence)
}
}
func TestParseICSMultipleEvents(t *testing.T) {
var sb strings.Builder
sb.WriteString("BEGIN:VCALENDAR\r\n")
for i, title := range []string{"甲", "乙", "丙"} {
sb.WriteString("BEGIN:VEVENT\r\n")
sb.WriteString("SUMMARY:" + title + "\r\n")
sb.WriteString("DTSTART:2026090" + string(rune('1'+i)) + "T020000Z\r\n")
sb.WriteString("END:VEVENT\r\n")
}
sb.WriteString("END:VCALENDAR\r\n")
events := parseICS([]byte(sb.String()))
if len(events) != 3 {
t.Fatalf("期望 3 个事件,得到 %d", len(events))
}
for i, want := range []string{"甲", "乙", "丙"} {
if events[i].Title != want {
t.Errorf("第 %d 个 Title = %q期望 %q", i, events[i].Title, want)
}
}
}
// 没有 DTSTART 的 VEVENT 必须被丢弃:让它进库会得到一个 zero time 事件,
// 调度器认为它「早就该触发了」,于是立刻发一封莫名其妙的提醒。
func TestParseICSDropsEventWithoutStart(t *testing.T) {
ics := "BEGIN:VCALENDAR\r\n" +
"BEGIN:VEVENT\r\n" +
"SUMMARY:没有时间\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
if events := parseICS([]byte(ics)); len(events) != 0 {
t.Fatalf("无 DTSTART 的事件应被丢弃,却得到 %d 个", len(events))
}
}
func TestParseICSRecurrence(t *testing.T) {
cases := []struct {
rrule string
want string
}{
{"FREQ=DAILY", "daily"},
{"FREQ=WEEKLY;BYDAY=MO", "weekly"},
{"FREQ=MONTHLY;BYMONTHDAY=1", "monthly"},
{"FREQ=YEARLY", "yearly"},
// 不支持的频率退回 none 而不是乱猜:把 HOURLY 当 daily
// 会让提醒少发 23 次且没有任何报错。
{"FREQ=HOURLY", "none"},
{"FREQ=SECONDLY", "none"},
}
for _, c := range cases {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
"RRULE:" + c.rrule + "\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatalf("%s: 期望 1 个事件", c.rrule)
}
if events[0].Recurrence != c.want {
t.Errorf("%s: Recurrence = %q期望 %q", c.rrule, events[0].Recurrence, c.want)
}
}
}
func TestParseICSTriggerToRemindBefore(t *testing.T) {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
"TRIGGER:-PT30M\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatal("期望 1 个事件")
}
if events[0].RemindBefore != 30 {
t.Errorf("RemindBefore = %d期望 30", events[0].RemindBefore)
}
}
// DTSTART 有三种合法形态,都得认。只认 UTC 那种会让本地时间的 .ics
// 整份导入失败(每个事件都缺 DTSTART → 全被丢弃 → 「导入 0 个」且无提示)。
func TestParseICSDateFormats(t *testing.T) {
cases := []struct {
name string
value string
}{
{"UTC", "20260903T063000Z"},
{"本地时间", "20260903T143000"},
{"仅日期", "20260903"},
}
for _, c := range cases {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:" + c.value + "\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Errorf("%s (%s): 期望 1 个事件,得到 %d", c.name, c.value, len(events))
continue
}
if events[0].EventTime.IsZero() {
t.Errorf("%s (%s): EventTime 为零值", c.name, c.value)
}
}
}
// DTSTART;TZID=Asia/Shanghai:... 这种带参数的键必须归一化到 DTSTART
// 否则 switch 落空 → 无 EventTime → 事件被丢。
func TestParseICSStripsKeyParameters(t *testing.T) {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\n" +
"DTSTART;TZID=Asia/Shanghai:20260903T143000\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatalf("带 TZID 参数的 DTSTART 应被识别,得到 %d 个事件", len(events))
}
if events[0].EventTime.IsZero() {
t.Error("EventTime 为零值")
}
}
func TestParseICSEscapedNewlines(t *testing.T) {
ics := "BEGIN:VEVENT\r\nSUMMARY:第一行\\n第二行\r\n" +
"DTSTART:20260903T020000Z\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatal("期望 1 个事件")
}
if !strings.Contains(events[0].Title, "\n") {
t.Errorf("转义的 \\n 应还原成真换行,得到 %q", events[0].Title)
}
}
func TestParseICSEmptyAndGarbage(t *testing.T) {
for _, in := range []string{"", "不是 ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"} {
if events := parseICS([]byte(in)); len(events) != 0 {
t.Errorf("输入 %q 应给 0 个事件,得到 %d", in, len(events))
}
}
}
// LF 换行(非 CRLF的 .ics 也要能解析:很多工具导出的是 LF。
func TestParseICSAcceptsLFLineEndings(t *testing.T) {
ics := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:LF 换行\n" +
"DTSTART:20260903T020000Z\nEND:VEVENT\nEND:VCALENDAR\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatalf("LF 换行应能解析,得到 %d 个事件", len(events))
}
if events[0].Title != "LF 换行" {
t.Errorf("Title = %q", events[0].Title)
}
}
// TRIGGER duration 解析。
//
// 原实现是 fmt.Sscanf(value, "-PT%dM", &mins),只认一种形态;导出端又写的是
// `-P15M`T 之前的 M 在 iCal 里是**月**)—— 于是自己导出的文件自己都读不回来。
func TestParseTriggerMinutes(t *testing.T) {
cases := []struct {
in string
want int
ok bool
}{
{"-PT30M", 30, true},
{"-PT1H", 60, true},
{"-PT1H30M", 90, true},
{"-P1D", 1440, true},
{"-P1DT2H", 1560, true},
{"-P1W", 10080, true},
{"-pt45m", 45, true}, // 大小写不敏感
// T 之前的 M 是月,映射不到分钟数,忽略比乱换算好
{"-P3M", 0, false},
// 正号 = 事件之后提醒remind_before 表达不了
{"PT30M", 0, false},
// 零时长与垃圾输入
{"-PT0M", 0, false},
{"", 0, false},
{"垃圾", 0, false},
{"-P", 0, false},
}
for _, c := range cases {
got, ok := parseTriggerMinutes(c.in)
if ok != c.ok || got != c.want {
t.Errorf("parseTriggerMinutes(%q) = (%d, %v),期望 (%d, %v)",
c.in, got, ok, c.want, c.ok)
}
}
}
// 导出写的 TRIGGER 必须能被自己的导入解析回同一个分钟数。
// 这条往返曾经是断的:导出 -P15M、导入找 -PT%dM。
func TestTriggerRoundtrip(t *testing.T) {
for _, mins := range []int{5, 15, 30, 60, 120, 1440} {
// 导出端的写法(与 ExportCalendarICS 里那行一致)
trigger := fmt.Sprintf("-PT%dM", mins)
got, ok := parseTriggerMinutes(trigger)
if !ok {
t.Errorf("%d 分钟导出成 %q 后无法解析", mins, trigger)
continue
}
if got != mins {
t.Errorf("%d 分钟往返后变成 %dtrigger=%q", mins, got, trigger)
}
}
}
// 整份 .ics 的往返TRIGGER 经过 parseICS 后落到 RemindBefore 上。
func TestParseICSTriggerVariants(t *testing.T) {
cases := []struct {
trigger string
want int
}{
{"-PT15M", 15},
{"-PT2H", 120},
{"-P1D", 1440},
}
for _, c := range cases {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
"TRIGGER:" + c.trigger + "\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Errorf("%s: 期望 1 个事件", c.trigger)
continue
}
if events[0].RemindBefore != c.want {
t.Errorf("%s: RemindBefore = %d期望 %d", c.trigger, events[0].RemindBefore, c.want)
}
}
}
// 默认提醒模板必须是**变量形式**,不能把当时的时间烤成字面值。
//
// 重复事件上这个区别是致命的AdvanceRecurrence 只推进 event_time
// reminder_text 保持不动 —— 字面值会让「每天 9 点」的提醒从第二天起
// 永远写着第一天的日期,且不报任何错。
//
// 同时锁住「与前端 DEFAULT_TEMPLATE 逐字一致」:
// web/src/components/CalendarEventEditor.tsx 用它作预览,
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
func TestDefaultReminderTemplateUsesVariables(t *testing.T) {
for _, v := range []string{"{title}", "{time}", "{description}"} {
if !strings.Contains(defaultReminderTemplate, v) {
t.Errorf("默认模板缺变量 %s%q", v, defaultReminderTemplate)
}
}
// 前端那份的字面内容(保持同步)
const frontend = "日程提醒:{title}\n时间{time}\n{description}"
if defaultReminderTemplate != frontend {
t.Errorf("后端默认模板与前端 DEFAULT_TEMPLATE 不一致:\n后端 %q\n前端 %q",
defaultReminderTemplate, frontend)
}
// 不该含任何形如年份的字面数字 —— 那是「把值烤进模板」的迹象
for _, digit := range []string{"2026", "20:", ":00"} {
if strings.Contains(defaultReminderTemplate, digit) {
t.Errorf("默认模板含字面时间片段 %q%q", digit, defaultReminderTemplate)
}
}
}
// ─── 农历与多收件人的 iCal 往返 ───
// 农历规则 RFC 5545 表达不了。折中方案X- 扩展属性记精确规则 +
// 降级成最接近的公历 RRULE。别的客户端至少能看到一个大致对的重复
// 导回本系统时 X- 属性还原精确规则。
func TestParseICSLunarRecurrenceExtension(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{
"X- 属性覆盖 RRULE 近似值",
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_monthly\r\n",
"lunar_monthly",
},
{
"农历年",
"RRULE:FREQ=YEARLY\r\nX-AGENTMAIL-RECURRENCE:lunar_yearly\r\n",
"lunar_yearly",
},
{
"X- 在 RRULE 之前也生效(顺序无关)",
"X-AGENTMAIL-RECURRENCE:lunar_monthly\r\nRRULE:FREQ=MONTHLY\r\n",
"lunar_monthly",
},
{
"非法 X- 值被忽略,保留 RRULE 的近似值",
"RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_montly\r\n",
"monthly",
},
{
"键名小写也认RFC 5545 §3.1 大小写不敏感)",
"x-agentmail-recurrence:lunar_yearly\r\n",
"lunar_yearly",
},
}
for _, c := range cases {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
c.body + "END:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Errorf("%s: 期望 1 个事件,得到 %d", c.name, len(events))
continue
}
if events[0].Recurrence != c.want {
t.Errorf("%s: Recurrence = %q期望 %q", c.name, events[0].Recurrence, c.want)
}
}
}
func TestParseICSRecipientsExtension(t *testing.T) {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
"X-AGENTMAIL-RECIPIENTS:pi@/home/x,dsh,opencode@/tmp.alias\r\n" +
"X-AGENTMAIL-DELIVERY:together\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatalf("期望 1 个事件,得到 %d", len(events))
}
e := events[0]
if len(e.Recipients) != 3 {
t.Fatalf("收件人应有 3 个,得到 %d%v", len(e.Recipients), e.Recipients)
}
if e.Recipients[0] != "pi@/home/x" || e.Recipients[2] != "opencode@/tmp.alias" {
t.Errorf("收件人内容或顺序不符:%v", e.Recipients)
}
if e.DeliveryMode != "together" {
t.Errorf("DeliveryMode = %q期望 together", e.DeliveryMode)
}
}
// 未知投递模式必须被忽略(留空 → EffectiveDeliveryMode 给 separate
// 而不是原样写进库里。
func TestParseICSRejectsBadDeliveryMode(t *testing.T) {
ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" +
"X-AGENTMAIL-DELIVERY:随便写的\r\nEND:VEVENT\r\n"
events := parseICS([]byte(ics))
if len(events) != 1 {
t.Fatal("期望 1 个事件")
}
if events[0].DeliveryMode != "" {
t.Errorf("非法投递模式应被忽略,得到 %q", events[0].DeliveryMode)
}
if events[0].EffectiveDeliveryMode() != models.DeliverSeparate {
t.Error("兜底应是 separate")
}
}
func TestValidRecurrence(t *testing.T) {
for _, ok := range []string{"none", "daily", "weekly", "monthly", "yearly", "lunar_monthly", "lunar_yearly"} {
if !validRecurrence(ok) {
t.Errorf("%q 应合法", ok)
}
}
// 拼错必须被拒而不是静默当 none —— 后者会让每月提醒只响一次且无报错
for _, bad := range []string{"", "lunar_montly", "LUNAR_MONTHLY", "每月", "lunar_weekly"} {
if validRecurrence(bad) {
t.Errorf("%q 应非法", bad)
}
}
}
func TestNormalizeRecipients(t *testing.T) {
got, bad := normalizeRecipients([]string{" pi ", "", "dsh", "pi", " "})
if bad != "" {
t.Fatalf("不该报错,得到 %q", bad)
}
// 去空白 + 去重,保留首次出现的顺序
if len(got) != 2 || got[0] != "pi" || got[1] != "dsh" {
t.Errorf("清洗结果 %v期望 [pi dsh]", got)
}
// 去重是必要的together 模式下同一 Agent 既主收又抄送会收到两条 SSE
if dup, _ := normalizeRecipients([]string{"pi@/x", "pi@/x"}); len(dup) != 1 {
t.Errorf("重复地址应去重,得到 %v", dup)
}
// 非法地址回报具体是哪一个
if _, bad := normalizeRecipients([]string{"pi", "@@@bad@@@"}); bad == "" {
t.Error("非法地址应被报出")
}
// nil / 空输入给空数组而不是 nil避免序列化成 null
if out, _ := normalizeRecipients(nil); out == nil {
t.Error("nil 输入应给空数组")
}
}