807 lines
26 KiB
Go
807 lines
26 KiB
Go
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_time,reminder_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
|
||
}
|