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

807 lines
26 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"`
// Status 在创建时存在只为与更新端点同形:前端的 CalendarEventInput 是
// **一份**类型,新建与编辑发的是同一个对象。不接这个字段的后果在
// 严格解码下是新建日程直接 400。
//
// 新建时它只能是 active新建一个已取消的提醒没有意义
// 但传 paused/cancelled 也不报错 —— 照字面履行比推回去更有用。
Status string `json:"status"`
}
if !DecodeBody(w, r, &req) {
return
}
if req.Title == "" {
Error(w, http.StatusBadRequest, "Missing title")
return
}
if req.Status == "" {
req.Status = models.EventActive
}
if !validEventStatus(req.Status) {
Error(w, http.StatusBadRequest,
"status 必须是 active/paused/cancelled 之一")
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
// 也是这一份client/electron/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: req.Status,
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
}
// status 直接写进库,所以必须先校验:一个拼错的值(比如 "pause")会变成
// 调度器不认识的状态 —— DueEvents 只查 active那条提醒于是静默失效
// 而界面下拉框里没有这个选项,人再也改不回来。
if req.Status == "" {
req.Status = models.EventActive
}
if !validEventStatus(req.Status) {
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled 之一")
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`(拼错)静默当成
// 不重复,用户设的每月提醒只会响一次,而没有任何地方报错。
// validEventStatus 校验日历事件状态(包装 models.ValidEventStatus
// validRecurrence 保持同一种调用形状)。
func validEventStatus(s string) bool {
return models.ValidEventStatus(s)
}
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
}