Files
MailUI4Agents/server/internal/handler/agent_calendar.go

455 lines
15 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 (
"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_timeRFC3339例如 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})
}