chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,806 @@
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
}