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

776 lines
25 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 (
"crypto/sha256"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/agentmail/gateway/internal/blob"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
// defaultReminderTemplate 是提醒正文的默认模板。
//
// 存变量而非字面值:{title}/{time}/{description} 在触发时由
// scheduler.RenderReminder 替换。与前端 CalendarEventEditor 的
// DEFAULT_TEMPLATE 必须逐字一致 —— 前端用它作 placeholder 与预览,
// 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。
const defaultReminderTemplate = "日程提醒:{title}\n时间{time}\n{description}"
// ─── Calendar Events ───
// POST /api/v1/calendar/events
func CreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
ReminderText string `json:"reminder_text"`
AgentName string `json:"agent_name"`
ToAddress string `json:"to_address"`
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 req.Title == "" {
Error(w, http.StatusBadRequest, "Missing title")
return
}
if req.EventTime.IsZero() {
Error(w, http.StatusBadRequest, "Missing event_time")
return
}
if req.ReminderText == "" {
// 默认模板必须存**变量形式**而不是把值烤进去。
//
// 原来这里 Sprintf 出一份含字面时间的正文。对重复事件是错的:
// AdvanceRecurrence 只推进 event_timereminder_text 保持不动 ——
// 于是「每天 9 点」的提醒从第二天起永远写着第一天的日期,
// Agent 收到的信里时间与实际触发时刻越差越远。
//
// 变量形式由 scheduler.RenderReminder 在**触发时**替换,
// 每一次触发都拿当时的 event_time。前端的 DEFAULT_TEMPLATE
// 也是这一份web/src/components/CalendarEventEditor.tsx
// 两处必须一致,否则预览与实发不符。
req.ReminderText = defaultReminderTemplate
}
if req.Recurrence == "" {
req.Recurrence = models.RecurNone
}
if !validRecurrence(req.Recurrence) {
Error(w, http.StatusBadRequest,
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
return
}
recipients, badAddr := normalizeRecipients(req.Recipients)
if badAddr != "" {
// 地址在这里就校验而不是等到触发时:建事件时报错人能立刻改,
// 而触发时报错只会进 journalctl —— 人以为提醒设好了,实际永远发不出去。
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
return
}
// 收件人一个都没有时事件永远发不出去,这不该静默通过
if len(recipients) == 0 && strings.TrimSpace(req.ToAddress) == "" &&
strings.TrimSpace(req.AgentName) == "" {
Error(w, http.StatusBadRequest, "至少要有一个收件人recipients / to_address / agent_name")
return
}
if req.DeliveryMode == "" {
req.DeliveryMode = models.DeliverSeparate
}
e := &models.CalendarEvent{
Title: req.Title,
Description: req.Description,
ReminderText: req.ReminderText,
AgentName: req.AgentName,
ToAddress: req.ToAddress,
Recipients: recipients,
DeliveryMode: req.DeliveryMode,
EventTime: req.EventTime,
RemindBefore: req.RemindBefore,
Recurrence: req.Recurrence,
RecurrenceEnd: req.RecurrenceEnd,
Status: "active",
CreatedBy: user.Username,
}
if _, err := repo.CreateCalendarEvent(r.Context(), e); err != nil {
Error(w, http.StatusInternalServerError, "Failed to create event")
return
}
JSON(w, http.StatusCreated, e)
}
// GET /api/v1/calendar/events?from=...&to=...
func ListCalendarEvents(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
status := r.URL.Query().Get("status")
var from, to time.Time
if fromStr != "" {
from, _ = time.Parse(time.RFC3339, fromStr)
}
if toStr != "" {
to, _ = time.Parse(time.RFC3339, toStr)
}
if to.IsZero() {
to = time.Now().AddDate(0, 1, 0) // 默认往后一个月
}
if from.IsZero() {
from = time.Now().AddDate(0, -1, 0) // 默认往前一个月
}
events, err := repo.ListCalendarEvents(r.Context(), from, to, status)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list events")
return
}
if events == nil {
events = []models.CalendarEvent{}
}
JSON(w, http.StatusOK, map[string]interface{}{
"events": events,
})
}
// GET /api/v1/calendar/events/{id}
func GetCalendarEvent(w http.ResponseWriter, r *http.Request) {
if _, ok := pathUUID(w, r, "id"); !ok {
return
}
eventID := chi.URLParam(r, "id")
e, err := repo.GetCalendarEvent(r.Context(), eventID)
if err != nil {
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "Event not found")
return
}
Error(w, http.StatusInternalServerError, "Failed to get event")
return
}
JSON(w, http.StatusOK, e)
}
// PUT /api/v1/calendar/events/{id}
func UpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if _, ok := pathUUID(w, r, "id"); !ok {
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
ReminderText string `json:"reminder_text"`
AgentName string `json:"agent_name"`
ToAddress string `json:"to_address"`
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
}
if req.Recurrence != "" && !validRecurrence(req.Recurrence) {
Error(w, http.StatusBadRequest,
"recurrence 必须是 none/daily/weekly/monthly/lunar_monthly/lunar_yearly 之一")
return
}
recipients, badAddr := normalizeRecipients(req.Recipients)
if badAddr != "" {
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
return
}
e := &models.CalendarEvent{
Title: req.Title,
Description: req.Description,
ReminderText: req.ReminderText,
AgentName: req.AgentName,
ToAddress: req.ToAddress,
Recipients: recipients,
DeliveryMode: req.DeliveryMode,
EventTime: req.EventTime,
RemindBefore: req.RemindBefore,
Recurrence: req.Recurrence,
RecurrenceEnd: req.RecurrenceEnd,
Status: req.Status,
}
if err := repo.UpdateCalendarEvent(r.Context(), eventID, e); err != nil {
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "Event not found")
return
}
Error(w, http.StatusInternalServerError, "Failed to update event")
return
}
JSON(w, http.StatusOK, e)
}
// DELETE /api/v1/calendar/events/{id}
func DeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if err := repo.DeleteCalendarEvent(r.Context(), eventID); err != nil {
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "Event not found")
return
}
Error(w, http.StatusInternalServerError, "Failed to delete event")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ─── Calendar Attachments ───
// POST /api/v1/calendar/events/{id}/attachments
func UploadCalendarAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
if Blobs == nil {
Error(w, http.StatusServiceUnavailable, "附件存储未初始化")
return
}
eventID := strings.TrimSpace(chi.URLParam(r, "id"))
if eventID == "" {
Error(w, http.StatusBadRequest, "Missing event id")
return
}
// 事件必须存在:否则会攒下一堆孤儿附件记录,而 ON DELETE CASCADE
// 永远清不掉它们(没有对应的父行可删)。
if _, err := repo.GetCalendarEvent(r.Context(), eventID); err != nil {
Error(w, http.StatusNotFound, "事件不存在")
return
}
max := config.C.MaxAttachmentBytes
// 与邮件附件同一套双层限制:外层卡整个请求体(含 multipart 边界),
// blob.Put 的 max 卡单个文件内容。少了外层,超大 multipart 头能拖死内存。
r.Body = http.MaxBytesReader(w, r.Body, max+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
Error(w, http.StatusBadRequest, "解析 multipart 失败(是否超过大小上限?)")
return
}
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
file, header, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusBadRequest, "缺少 file 字段")
return
}
defer file.Close()
// 原来这里是 `data := make([]byte, header.Size); file.Read(data)` ——
// 两处错:单次 Read 不保证填满缓冲大文件必然短读sha256 因此算的是
// 半截内容),而且**文件内容从未落盘**,只往库里写了一条元数据。
// 结果是附件"上传成功"、清单里看得见、发提醒时取不到任何字节。
sum, size, err := Blobs.Put(file, max)
if errors.Is(err, blob.ErrTooLarge) {
Error(w, http.StatusRequestEntityTooLarge,
fmt.Sprintf("附件超过上限 %.1f MB", float64(max)/(1<<20)))
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "保存附件失败")
return
}
att := &models.CalendarAttachment{
EventID: eventID,
Filename: sanitizeFilename(header.Filename),
SizeBytes: size,
SHA256: sum,
}
if err := repo.AddCalendarAttachment(r.Context(), att); err != nil {
// 落盘成功但入库失败:孤立文件由 GC 回收,不影响正确性
Error(w, http.StatusInternalServerError, "登记附件失败")
return
}
JSON(w, http.StatusCreated, att)
}
// GET /api/v1/calendar/events/{id}/attachments
func ListCalendarAttachments(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
atts, err := repo.ListCalendarAttachments(r.Context(), eventID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list attachments")
return
}
if atts == nil {
atts = []models.CalendarAttachment{}
}
JSON(w, http.StatusOK, map[string]interface{}{"attachments": atts})
}
// DELETE /api/v1/calendar/events/{id}/attachments/{aid}
func DeleteCalendarAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
attID := strings.TrimSpace(chi.URLParam(r, "attachmentID"))
if attID == "" {
Error(w, http.StatusBadRequest, "Missing attachment id")
return
}
// 原来这里返回 501 并让人「删整个事件来清附件」—— 那要求人为了撤掉
// 一个错传的文件把整条日程连提醒配置一起重建。
//
// 磁盘上的 blob 不在这里删:内容寻址下同一个 sha256 可能被别的附件
// (甚至别的邮件)引用着,删文件会让那些引用一起坏掉。孤立 blob 归 GC。
ok, err := repo.DeleteCalendarAttachment(r.Context(), attID)
if err != nil {
Error(w, http.StatusInternalServerError, "删除附件失败")
return
}
if !ok {
Error(w, http.StatusNotFound, "附件不存在")
return
}
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "attachment_id": attID})
}
// validRecurrence 白名单校验重复规则。
//
// 必须白名单而不是「未知值当 none」把 `lunar_montly`(拼错)静默当成
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
func validRecurrence(r string) bool {
switch r {
case models.RecurNone, models.RecurDaily, models.RecurWeekly, models.RecurMonthly,
models.RecurYearly, models.RecurLunarMonthly, models.RecurLunarYearly:
return true
}
return false
}
// normalizeRecipients 清洗收件人列表:去空白、去重、校验地址可解析。
//
// 返回第二个值非空表示有地址解析失败(值即那个地址),调用方回 400。
// 在建事件时校验而不是等触发:建事件时报错人能立刻改,
// 触发时报错只会进 journalctl —— 人以为设好了,实际永远发不出去。
//
// 去重是必要的together 模式下同一个 Agent 既是主收件人又在抄送里,
// 会让它收到两条一模一样的 SSE插件可能因此起两轮。
func normalizeRecipients(in []string) ([]string, string) {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, raw := range in {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if _, err := models.ParseAddress(raw); err != nil {
return nil, raw
}
if seen[raw] {
continue
}
seen[raw] = true
out = append(out, raw)
}
return out, ""
}
// ─── iCal 导入导出 ───
// GET /api/v1/calendar/export.ics
func ExportCalendarICS(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
// 区间取自查询参数:前端导出的是「当前正在看的那段」,
// 写死 ±1 年会让人点导出后得到一堆与屏幕上不符的事件。
from := time.Now().AddDate(-1, 0, 0)
to := time.Now().AddDate(1, 0, 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
}
}
events, err := repo.ListCalendarEvents(r.Context(), from, to, "active")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list events")
return
}
var sb strings.Builder
sb.WriteString("BEGIN:VCALENDAR\r\n")
sb.WriteString("VERSION:2.0\r\n")
sb.WriteString("PRODID:-//AgentMail//Calendar//EN\r\n")
for _, e := range events {
sb.WriteString("BEGIN:VEVENT\r\n")
fmt.Fprintf(&sb, "UID:%s@agentmail\r\n", e.EventID)
fmt.Fprintf(&sb, "DTSTAMP:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
fmt.Fprintf(&sb, "DTSTART:%s\r\n", e.EventTime.UTC().Format("20060102T150405Z"))
// 默认 1 小时持续时间
fmt.Fprintf(&sb, "DTEND:%s\r\n", e.EventTime.Add(time.Hour).UTC().Format("20060102T150405Z"))
// 转义换行
summary := strings.ReplaceAll(e.Title, "\n", "\\n")
fmt.Fprintf(&sb, "SUMMARY:%s\r\n", summary)
if e.Description != "" {
desc := strings.ReplaceAll(e.Description, "\n", "\\n")
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", desc)
}
if e.Recurrence != "none" {
var freq string
switch e.Recurrence {
case "daily":
freq = "DAILY"
case "weekly":
freq = "WEEKLY"
case "monthly":
freq = "MONTHLY"
case "yearly":
freq = "YEARLY"
}
if freq != "" {
fmt.Fprintf(&sb, "RRULE:FREQ=%s\r\n", freq)
}
// 农历规则 RFC 5545 表达不了RRULE 只有公历频率)。
//
// 折中:用 X- 扩展属性记下真实规则,并把它降级成最接近的公历
// 近似lunar_monthly → MONTHLY、lunar_yearly → YEARLY
// 别的客户端至少能看到一个大致对的重复;导回本系统时
// X- 属性会把精确规则还原。
//
// 不写近似 RRULE 的后果更糟:外部客户端会把它当一次性事件,
// 用户以为导出的日历里有「每年农历生日」,实际只有一条。
if models.IsLunarRecurrence(e.Recurrence) {
fmt.Fprintf(&sb, "X-AGENTMAIL-RECURRENCE:%s\r\n", e.Recurrence)
if e.Recurrence == models.RecurLunarMonthly {
sb.WriteString("RRULE:FREQ=MONTHLY\r\n")
} else {
sb.WriteString("RRULE:FREQ=YEARLY\r\n")
}
}
}
// VALARM 的 TRIGGER 必须写成 `-PT<n>M`。
//
// 两个坑iCal 的 duration 里 `M` **在 T 之前是月、在 T 之后才是分钟** ——
// 原来写的 `-P15M` 在任何合规日历客户端里都是「提前 15 个月」。
// 而且原来用 maxInt(RemindBefore, 15) 兜底,把用户明确设的
// 「到点提醒」(0) 悄悄改成提前 15 分钟;导出不该修改语义。
// 收件人与投递模式同样没有标准字段可放。
// 不导出的后果:往返一圈后事件变成「没有收件人」,永远不会提醒。
if rs := e.EffectiveRecipients(); len(rs) > 0 {
fmt.Fprintf(&sb, "X-AGENTMAIL-RECIPIENTS:%s\r\n", strings.Join(rs, ","))
fmt.Fprintf(&sb, "X-AGENTMAIL-DELIVERY:%s\r\n", e.EffectiveDeliveryMode())
}
fmt.Fprintf(&sb, "BEGIN:VALARM\r\n")
fmt.Fprintf(&sb, "TRIGGER:-PT%dM\r\n", e.RemindBefore)
fmt.Fprintf(&sb, "ACTION:DISPLAY\r\n")
fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", summary)
fmt.Fprintf(&sb, "END:VALARM\r\n")
sb.WriteString("END:VEVENT\r\n")
}
sb.WriteString("END:VCALENDAR\r\n")
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="agentmail-calendar.ics"`)
w.Write([]byte(sb.String()))
}
// POST /api/v1/calendar/import.ics
func ImportCalendarICS(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
// 两种上传形态都接受。
//
// multipart 是浏览器 <input type=file> 的天然形态raw text/calendar 是
// 脚本与 Agent 的天然形态curl --data-binary @x.ics。只支持前者会让
// 命令行调用者收到含糊的「Missing file field」只支持后者则要求前端
// 先把文件读成字符串再发 —— 两边各让一步不如两边都收。
var body []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/") {
if err := r.ParseMultipartForm(10 << 20); err != nil {
Error(w, http.StatusBadRequest, "Failed to parse multipart: "+err.Error())
return
}
file, _, err := r.FormFile("file")
if err != nil {
Error(w, http.StatusBadRequest, "Missing file field")
return
}
defer file.Close()
body, err = io.ReadAll(file)
if err != nil {
Error(w, http.StatusBadRequest, "Failed to read file")
return
}
} else {
var err error
body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
if err != nil {
Error(w, http.StatusBadRequest, "Failed to read body")
return
}
}
if len(body) == 0 {
Error(w, http.StatusBadRequest, "Empty .ics payload")
return
}
events := parseICS(body)
imported := 0
for _, e := range events {
e.CreatedBy = user.Username
e.Status = "active"
if _, err := repo.CreateCalendarEvent(r.Context(), &e); err == nil {
imported++
}
}
// skipped 单独给出而不是让前端自己减insert 失败(撞名、约束冲突)
// 与「解析出来但没入库」是同一回事,前端只关心「有几个没进来」。
JSON(w, http.StatusOK, map[string]interface{}{
"imported": imported,
"skipped": len(events) - imported,
"total": len(events),
})
}
// ─── iCal 解析 ───
func parseICS(data []byte) []models.CalendarEvent {
var events []models.CalendarEvent
var current *models.CalendarEvent
lines := strings.Split(string(data), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
// 处理折叠行iCal 的续行以空格开头)
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
if current != nil && len(events) > 0 {
// 简单续行处理:追加到最后一个字段
}
continue
}
colon := strings.Index(line, ":")
if colon < 0 {
continue
}
key := line[:colon]
value := line[colon+1:]
// 去掉参数部分(如 DTSTART;TZID=...:value
if semi := strings.Index(key, ";"); semi >= 0 {
key = key[:semi]
}
// 键名大小写不敏感RFC 5545 §3.1。X- 扩展属性尤其容易被
// 其他客户端改写大小写,不归一化会让往返丢掉农历规则。
key = strings.ToUpper(key)
switch key {
case "BEGIN":
if value == "VEVENT" {
current = &models.CalendarEvent{Recurrence: "none"}
}
case "END":
if value == "VEVENT" && current != nil {
if !current.EventTime.IsZero() {
events = append(events, *current)
}
current = nil
}
case "SUMMARY":
if current != nil {
current.Title = strings.ReplaceAll(value, "\\n", "\n")
}
case "DESCRIPTION":
if current != nil {
current.Description = strings.ReplaceAll(value, "\\n", "\n")
}
case "DTSTART":
if current != nil {
if t, err := time.Parse("20060102T150405Z", value); err == nil {
current.EventTime = t
} else if t, err := time.ParseInLocation("20060102T150405", value, time.Local); err == nil {
current.EventTime = t
} else if t, err := time.Parse("20060102", value); err == nil {
current.EventTime = t
}
}
case "RRULE":
// **不覆盖已经从 X-AGENTMAIL-RECURRENCE 读到的农历规则。**
//
// 导出时农历事件同时写了 X- 精确值与一条公历近似 RRULE
// 给别的客户端看。X- 出现在 RRULE 之前时,
// 若这里无条件赋值就会把精确的 lunar_monthly 打回 monthly
// —— 往返一圈农历规则悄悄退化成公历,用户要过一个月才发现
// 提醒日子不对。
if current != nil && !models.IsLunarRecurrence(current.Recurrence) {
v := strings.ToUpper(value)
switch {
case strings.Contains(v, "FREQ=DAILY"):
current.Recurrence = models.RecurDaily
case strings.Contains(v, "FREQ=WEEKLY"):
current.Recurrence = models.RecurWeekly
case strings.Contains(v, "FREQ=MONTHLY"):
current.Recurrence = models.RecurMonthly
case strings.Contains(v, "FREQ=YEARLY"):
current.Recurrence = models.RecurYearly
}
}
case "TRIGGER":
if current != nil {
if mins, ok := parseTriggerMinutes(value); ok {
current.RemindBefore = mins
}
}
case "X-AGENTMAIL-RECURRENCE":
// 精确规则覆盖上面从 RRULE 猜出来的近似值。
// 顺序无关X- 属性只在值合法时才生效。
if current != nil && validRecurrence(value) {
current.Recurrence = value
}
case "X-AGENTMAIL-RECIPIENTS":
if current != nil {
list, bad := normalizeRecipients(strings.Split(value, ","))
// 单个地址坏掉不该让整份导入失败:其余收件人仍有效。
// 全坏时 list 为空,事件会在 Create 时被收件人校验拦下。
if bad == "" {
current.Recipients = list
}
}
case "X-AGENTMAIL-DELIVERY":
if current != nil && (value == models.DeliverSeparate || value == models.DeliverTogether) {
current.DeliveryMode = value
}
}
}
return events
}
// ─── 辅助 ───
func blobSha256(data []byte) string {
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum[:])
}
// parseTriggerMinutes 把 VALARM 的 TRIGGER duration 解析成「提前多少分钟」。
//
// 接受 `-PT30M` / `-PT1H` / `-PT1H30M` / `-P1D` / `-P1DT2H` 这些形态。
// 关键规则:`M` 出现在 `T` **之后**才是分钟,之前是月 —— 按月的 trigger
// 无法映射到 remind_before那是个分钟数直接忽略比乱换算好。
//
// 正号事件之后提醒也忽略remind_before 语义上只能提前。
// 返回 ok=false 表示「这条 TRIGGER 用不上」,调用方保持原值不动。
func parseTriggerMinutes(v string) (int, bool) {
v = strings.TrimSpace(strings.ToUpper(v))
if !strings.HasPrefix(v, "-P") {
return 0, false
}
rest := v[2:]
// 切成 T 前后两段前面是日期部分Y/M/W/D后面是时间部分H/M/S
datePart, timePart := rest, ""
if i := strings.Index(rest, "T"); i >= 0 {
datePart, timePart = rest[:i], rest[i+1:]
}
total := 0
// 日期部分只认 W/D。Y/M 是可变长度(月有 28~31 天),换算成分钟只能靠猜。
if n, ok := durationField(datePart, 'W'); ok {
total += n * 7 * 24 * 60
}
if n, ok := durationField(datePart, 'D'); ok {
total += n * 24 * 60
}
if n, ok := durationField(timePart, 'H'); ok {
total += n * 60
}
if n, ok := durationField(timePart, 'M'); ok {
total += n
}
// 秒不进 remind_before它的粒度是分钟30 秒会被截成 0 而看不出区别
if total <= 0 {
return 0, false
}
return total, true
}
// durationField 从 `1H30M` 这样的串里取出紧接在 unit 之前的整数。
func durationField(s string, unit byte) (int, bool) {
idx := strings.IndexByte(s, unit)
if idx < 0 {
return 0, false
}
start := idx
for start > 0 && s[start-1] >= '0' && s[start-1] <= '9' {
start--
}
if start == idx {
return 0, false
}
n := 0
for i := start; i < idx; i++ {
n = n*10 + int(s[i]-'0')
}
return n, true
}