package handler import ( "errors" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" ) // Agent 侧的日历能力。 // // # 为什么 Agent 需要建日程 // // 在这之前日历是纯人类功能:`/calendar/*` 全挂在 `middleware.UserAuth` 后面, // Agent 密钥一律 401。于是「明天九点提醒我看 CI 结果」这件事,Agent 只能 // 在自己进程里 setTimeout —— 而它的进程随时会重启,定时器一并消失, // 那条提醒静默不见,没有任何地方留下痕迹。 // // 把日程放进 Gateway 之后,它由数据库与调度器保证:插件重启、Agent 换机器、 // 甚至换平台,提醒照样按时到达。 // // # 与人类端点的三处差异 // // 1. **只能看自己建的**(`ListCalendarEventsCreatedBy`)。别人的日程里 // 可能有它无权知道的会议与地址。 // 2. **只能改自己建的**。人建的提醒不该被 Agent 悄悄改时间或删掉 —— // 那等于让它绕过人的安排。 // 3. **有速率与总量双重上限**。见下面 `guardAgentQuota` 的说明。 // // # 为什么允许发给别人 // // 「让 pi 提醒 dsh 明天交周报」是真实需求:跨 Agent 的任务交接本来就是 // 这个平台的主题。收件人一律走完整三维寻址,与 send_mail 同一套解析, // 因此 Agent 能设的目标不会超出它本来就能发信的范围。 // // **但不能设给人类**:见 `rejectHumanRecipients`。 // ─── 配额守卫 ─── // maxActiveEventsPerAgent 是单个 Agent 同时生效的事件总量上限。 // // 为什么速率限制不够:`calendar:` 桶压住的是「一小时内建几条」, // 压不住「每小时建 19 条、连建一周」。而日历事件是**长效**的 —— // 一条每日重复提醒会一直发下去直到有人删它。攒下 300 条之后, // 即使 Agent 早已停止建新的,每天仍有 300 封提醒邮件涌出来。 // // 50 条:正常用法下一个 Agent 手上的长期日程是个位数; // 撞到 50 说明它在无意义地攒任务,此时报错比继续接受更有用。 const maxActiveEventsPerAgent = 50 // guardAgentQuota 检查速率与总量两道闸。 // // 返回 false 时已经写好响应,调用方直接 return。 // 第二个返回值是「本次已记账」,创建失败时调用方要 Release 归还。 func guardAgentQuota(w http.ResponseWriter, r *http.Request, agentName string) (ok bool, charged bool) { // 总量先查:它不消耗速率名额,撞上限时不该顺手扣一次 active, err := repo.CountActiveEventsBy(r.Context(), agentName) if err == nil && active >= maxActiveEventsPerAgent { Error(w, http.StatusTooManyRequests, "你当前已有 "+strconv.Itoa(active)+" 条生效中的日程(上限 "+strconv.Itoa(maxActiveEventsPerAgent)+ ")。请先删掉不需要的,或把多条合并成一条重复日程。") return false, false } if allowed, retry := repo.AllowAgentCalendarEvent(r.Context(), agentName); !allowed { Error(w, http.StatusTooManyRequests, "建日程过于频繁(1 小时内已建 "+strconv.Itoa(repo.CalendarRateLimit())+" 条)。"+ strconv.Itoa(retry)+" 秒后再试;如果只是想改时间,请用 PUT 改已有那条而不是新建。") return false, false } return true, true } // rejectHumanRecipients 拒绝把人类放进收件人列表。 // // 理由是**投递通道不对等**。Agent 之间的提醒是任务信号:收到就干活, // 干完回信,人不在环里也能推进。而发给人的提醒是打扰 —— 它会进人的收件箱、 // 触发未读徽标,而人无法「回信让它停下」(提醒是日历实体,不是对话)。 // // 一个 Agent 建一条「每 10 分钟提醒 jianf 检查进度」的日程,人就只能去 // WebUI 里找出那条事件删掉。给 Agent 这个能力,收益(它其实可以直接发邮件) // 远小于代价。 // // 人自己在界面上给自己设提醒不受此限 —— 那是人类端点的事。 func rejectHumanRecipients(w http.ResponseWriter, r *http.Request, recipients []string) bool { for _, raw := range recipients { addr, err := models.ParseAddress(raw) if err != nil { continue // 地址合法性由 normalizeRecipients 负责报错 } human, hErr := repo.IsHumanUser(r.Context(), addr.Name) if hErr != nil { // 查不动就放行:这道闸是防滥用,不该因为 DB 抖动而挡住正常请求 continue } if human { Error(w, http.StatusForbidden, "不能把日程提醒设给人类用户("+addr.Name+")。"+ "要通知人请直接 send_mail —— 那样他能回信,而定时提醒他只能去界面上删。") return false } } return true } // requireOwnEvent 读出事件并确认它是本 Agent 建的。 // // 返回 nil 时已写好响应。刻意对「不存在」与「不属于我」都回 404 —— // 回 403 会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。 func requireOwnEvent(w http.ResponseWriter, r *http.Request, agentName string) *models.CalendarEvent { id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { Error(w, http.StatusBadRequest, "Missing event id") return nil } e, err := repo.GetCalendarEvent(r.Context(), id) if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "日程不存在") return nil } if err != nil { Error(w, http.StatusInternalServerError, "读取日程失败") return nil } if e.CreatedBy != agentName { Error(w, http.StatusNotFound, "日程不存在") return nil } return e } // ─── 端点 ─── // POST /api/v1/agent/calendar/events func AgentCreateCalendarEvent(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } var req struct { Title string `json:"title"` Description string `json:"description"` ReminderText string `json:"reminder_text"` Recipients []string `json:"recipients"` DeliveryMode string `json:"delivery_mode"` EventTime time.Time `json:"event_time"` RemindBefore int `json:"remind_before"` Recurrence string `json:"recurrence"` RecurrenceEnd *time.Time `json:"recurrence_end"` } if !DecodeBody(w, r, &req) { return } if strings.TrimSpace(req.Title) == "" { Error(w, http.StatusBadRequest, "Missing title") return } if req.EventTime.IsZero() { Error(w, http.StatusBadRequest, "Missing event_time(RFC3339,例如 2026-09-10T09:00:00+08:00)") return } if req.Recurrence == "" { req.Recurrence = models.RecurNone } if !validRecurrence(req.Recurrence) { Error(w, http.StatusBadRequest, "recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一") return } // 收件人默认是自己:「提醒我明天看 CI」是最常见的用法, // 每次都要求写出自己的名字只会让模型忘记然后拿到 400。 recipients, badAddr := normalizeRecipients(req.Recipients) if badAddr != "" { Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr) return } if len(recipients) == 0 { recipients = []string{agentName} } if !rejectHumanRecipients(w, r, recipients) { return } // 一次性事件设在过去毫无意义:调度器下一轮就会立刻发出去, // 而模型的意图显然是「未来某时」。这几乎总是时区或年份写错。 // 重复事件不拦:一条「每天 9 点」的规则从昨天开始是合理的写法, // AdvanceRecurrence 会把它推到下一个未来时刻。 if req.Recurrence == models.RecurNone && req.EventTime.Before(time.Now()) { Error(w, http.StatusBadRequest, "event_time 在过去("+req.EventTime.Format(time.RFC3339)+ ")。一次性日程会立刻触发 —— 请检查时区与年份是否写对。") return } okQuota, charged := guardAgentQuota(w, r, agentName) if !okQuota { return } if strings.TrimSpace(req.ReminderText) == "" { req.ReminderText = defaultReminderTemplate } if req.DeliveryMode == "" { req.DeliveryMode = models.DeliverSeparate } e := &models.CalendarEvent{ Title: strings.TrimSpace(req.Title), Description: req.Description, ReminderText: req.ReminderText, Recipients: recipients, DeliveryMode: req.DeliveryMode, ToAddress: recipients[0], EventTime: req.EventTime, RemindBefore: req.RemindBefore, Recurrence: req.Recurrence, RecurrenceEnd: req.RecurrenceEnd, Status: "active", // created_by 记 Agent 名。它与 users.username 共用命名空间, // 因此不会与人类创建者混淆。 CreatedBy: agentName, } out, err := repo.CreateCalendarEvent(r.Context(), e) if err != nil { if charged { // 那次创建实际没有发生,名额还回去 repo.ReleaseAgentCalendarEvent(r.Context(), agentName) } Error(w, http.StatusInternalServerError, "创建日程失败") return } JSON(w, http.StatusCreated, out) } // GET /api/v1/agent/calendar/events // // 只返回本 Agent 建的事件。默认区间是「现在往后 90 天」—— // Agent 关心的是「接下来要发生什么」,不是翻历史。 func AgentListCalendarEvents(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } from := time.Now().Add(-24 * time.Hour) to := time.Now().AddDate(0, 3, 0) if v := r.URL.Query().Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { from = t } } if v := r.URL.Query().Get("to"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { to = t } } status := r.URL.Query().Get("status") if status == "" { status = "active" } if status == "all" { status = "" // repo 里空串 = 不过滤 } events, err := repo.ListCalendarEventsCreatedBy(r.Context(), agentName, from, to, status) if err != nil { Error(w, http.StatusInternalServerError, "读取日程失败") return } JSON(w, http.StatusOK, map[string]any{ "events": events, // 把上限一并回传:模型看到 42/50 才知道该清理了, // 只在撞墙时才用报文告知等于让它一直蒙在鼓里。 "active_limit": maxActiveEventsPerAgent, }) } // GET /api/v1/agent/calendar/events/{id} func AgentGetCalendarEvent(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } e := requireOwnEvent(w, r, agentName) if e == nil { return } JSON(w, http.StatusOK, e) } // PUT /api/v1/agent/calendar/events/{id} // // 部分更新:省略的字段保持原值。 // // 与人类端点(整体替换)不同,因为调用方是模型 —— 要求它每次都回传全部 // 字段,漏一个就会把提醒正文或收件人清空,而那种破坏没有任何报错。 func AgentUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } cur := requireOwnEvent(w, r, agentName) if cur == nil { return } // 全部用指针:nil = 没传 = 保持原值。 // 用值类型的话「传了空字符串想清空说明」与「没传」无法区分。 var req struct { Title *string `json:"title"` Description *string `json:"description"` ReminderText *string `json:"reminder_text"` Recipients *[]string `json:"recipients"` DeliveryMode *string `json:"delivery_mode"` EventTime *time.Time `json:"event_time"` RemindBefore *int `json:"remind_before"` Recurrence *string `json:"recurrence"` RecurrenceEnd *time.Time `json:"recurrence_end"` Status *string `json:"status"` } if !DecodeBody(w, r, &req) { return } next := *cur if req.Title != nil { if strings.TrimSpace(*req.Title) == "" { Error(w, http.StatusBadRequest, "title 不能为空") return } next.Title = strings.TrimSpace(*req.Title) } if req.Description != nil { next.Description = *req.Description } if req.ReminderText != nil { next.ReminderText = *req.ReminderText if strings.TrimSpace(next.ReminderText) == "" { next.ReminderText = defaultReminderTemplate } } if req.Recipients != nil { recipients, badAddr := normalizeRecipients(*req.Recipients) if badAddr != "" { Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr) return } if len(recipients) == 0 { Error(w, http.StatusBadRequest, "recipients 不能改成空 —— 那样日程永远不会提醒任何人") return } if !rejectHumanRecipients(w, r, recipients) { return } next.Recipients = recipients next.ToAddress = recipients[0] } if req.DeliveryMode != nil { next.DeliveryMode = *req.DeliveryMode } if req.EventTime != nil { if req.EventTime.IsZero() { Error(w, http.StatusBadRequest, "event_time 无效") return } next.EventTime = *req.EventTime } if req.RemindBefore != nil { if *req.RemindBefore < 0 { Error(w, http.StatusBadRequest, "remind_before 不能为负") return } next.RemindBefore = *req.RemindBefore } if req.Recurrence != nil { if !validRecurrence(*req.Recurrence) { Error(w, http.StatusBadRequest, "recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一") return } next.Recurrence = *req.Recurrence } if req.RecurrenceEnd != nil { next.RecurrenceEnd = req.RecurrenceEnd } if req.Status != nil { switch *req.Status { case "active", "paused", "cancelled": next.Status = *req.Status default: Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled") return } } // 改了时间就允许重新触发。 // // 不清 fired_for 的后果:把一条已触发的事件时间往后挪, // DueEvents 的判据 `fired_for <> event_time` 恰好又成立了 —— 这是对的; // 但把时间挪成**原值**(比如只改标题时前端回传了同一个时间)不该重发。 // 因此这里不主动清,靠 occurrence 相等自然判断即可。 if err := repo.UpdateCalendarEvent(r.Context(), next.EventID, &next); err != nil { if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "日程不存在") return } Error(w, http.StatusInternalServerError, "更新日程失败") return } out, err := repo.GetCalendarEvent(r.Context(), next.EventID) if err != nil { Error(w, http.StatusInternalServerError, "更新成功但读回失败") return } JSON(w, http.StatusOK, out) } // DELETE /api/v1/agent/calendar/events/{id} func AgentDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { agentName := middleware.GetAgentName(r) if agentName == "" { Error(w, http.StatusUnauthorized, "Unauthorized") return } e := requireOwnEvent(w, r, agentName) if e == nil { return } if err := repo.DeleteCalendarEvent(r.Context(), e.EventID); err != nil { if errors.Is(err, repo.ErrEventNotFound) { Error(w, http.StatusNotFound, "日程不存在") return } Error(w, http.StatusInternalServerError, "删除日程失败") return } // 附件随事件一起清:ON DELETE CASCADE 在 SQLite 下需要 foreign_keys=ON, // 而那个 pragma 默认是关的,不显式删会留下孤儿记录。 _ = repo.DeleteCalendarAttachments(r.Context(), e.EventID) JSON(w, http.StatusOK, map[string]any{"status": "deleted", "event_id": e.EventID}) }