Files
MailUI4Agents/server/cmd/server/main.go
JianFeeeee 2da38bba83 农历走服务端端点:换算只在服务端做一次(两边各写一遍天文算法迟早差一天)
用户定的方案:「加 api 端点」。

## 为什么不移植到 ArkTS

`lunar-javascript` 的 `lunar.js` 有 **43 万字节**,内部是**日月位置的级数展开**
(实测:全文件最大的数字字面量是 16KB 的系数数组,**不是**"某年到某年的月长表")。
即"照搬一张小数据表"这条路**不存在** —— 移植等于在 ArkTS 里再实现一遍天文算法。
两份实现迟早会在某个闰月或某个朔日上差一天,而那种错**表现为日期错位、不是报错**,
界面上完全看不出(用户得自己去查日历才知道)。

所以:`GET /api/v1/calendar/lunar?from=&to=`(服务端 `internal/lunar`,同一作者的 lunar-go)。

## 形状是「按日期键索引的映射」,不是数组

客户端拿到 `map[iso] -> 标签` 直接按格子键查,不用自己遍历比对。
`text` 字段是**格子里直接显示的那个串**(初一=月名、其余=日名)——
由服务端定,两端同源。客户端各拼一份的话,同一天在两边日历上可能长得不一样
(例如闰月到底写不写「闰」)。

几个刻意的取舍:
- **不设默认 from/to**:默认范围会让「我要 3 月」与「服务端以为我要这个月」悄悄不一致;
- 入参只收 `YYYY-MM-DD`(**日期键**,不是 RFC3339):农历是"这一天是农历几号"的
  纯日期语义,混用时间戳会被时区挪一天;
- 换不出来的日子**不进 map**(客户端查不到 ⇒ 那格不显示农历),而不是塞空对象 ——
  空对象会让客户端以为"有农历、只是没内容";
- 区间上限 400 天(不是安全边界,是防客户端传十年前到十年后)。

## 判据(`server/internal/handler/lunar_test.go`)

参照物是服务端的 `internal/lunar`(权威实现),**不抄一份答案表** —— 库升级时判据跟着走。
钉的点各自对着一个会静默出错的错法:
- `Full` 与权威实现逐字一致(5 个日期,含春节、跨世纪、29 天月的边界年份);
- 日名表覆盖 1..30 且**五种前缀形态都在**(WebUI 那版漏过「二十」);
- ★ 初一显示**月名**、其余显示**日名**(与 WebUI `cellLunarLabel()` 同一口径);
- 闰月必须带「闰」字(不带的话闰六月与六月在格子里一样);
- 极端值(公元 1 年 / 1900 / 2100 / 9999)**不许 panic**,且换出来时文字里不许含
  「无效/NaN」这类失败标记。

★ 最后一条我第一版**写错了**:断言「`time.Time{}` 应当换不出来」,实测库**换得出来**
  (0001-01-01 → 「〇年冬月十八」)—— 我断言的是自己的想象而不是实际行为。
  改成断言真正的契约(不 panic / 失败就不给 / 给了就得是真结果)后才对。

## 客户端

`LunarLabels` / `LunarRangeResponse` 两个模型 + `CalendarApi.listLunar(fromIso, toIso)`;
`CalendarPage` 在 `loadEvents()` 之后**不 await** 地拉农历(附加信息不该拖慢事件列表),
失败**不算 `this.error`**(否则"农历服务抖一下"会变成"整个日历打不开"),
只写 hilog 留痕。区间按**网格**取(不只本月 —— 月视图首尾显示上/下月格子)。
2026-09-18 11:23:12 +08:00

450 lines
18 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 main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/agentmail/gateway/internal/blob"
"github.com/agentmail/gateway/internal/config"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/handler"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/push"
"github.com/agentmail/gateway/internal/repo"
"github.com/agentmail/gateway/internal/scheduler"
"github.com/agentmail/gateway/internal/static"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func main() {
cfg := config.Load()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := db.Connect(ctx, cfg.DatabaseURL); err != nil {
log.Fatalf("Database connection failed: %v", err)
}
defer db.Close()
// 第一轮迁移:建表(users 必须先存在,'human' 数据迁移才能找到管理员)
if err := db.Migrate(ctx); err != nil {
log.Fatalf("Migration failed: %v", err)
}
// 确保存在默认管理员
bootstrapAdmin(ctx, cfg)
// 第二轮迁移:此时管理员已存在,历史 'human' 字面量得以重写
// (仅 PostgreSQL 有该历史包袹;SQLite 是新后端,这一轮是幂等的建表重跑)
if err := db.Migrate(ctx); err != nil {
log.Fatalf("Post-admin migration failed: %v", err)
}
// 附件存储:内容存盘,数据库只存元数据
blobs, err := blob.New(cfg.AttachmentDir)
if err != nil {
log.Fatalf("Attachment store failed: %v", err)
}
// 推送是**可选、配置式、多厂商**的旁路:配置在 PUSH_CONFIG(默认
// <AGENTMAIL_DATA_DIR>/push.json)里,一项一个厂商,凭证可以是密钥文件。
// 文件不存在 = 没配 = 正常状态(自部署实例常常如此),此时一个通道都不会注册。
// 单项配错只跳过那一项 —— 推送不该有能力让邮件服务起不来。
push.Setup()
handler.Blobs = blobs
fmt.Printf("附件存储:%s(单个上限 %.0f MB)\n",
blobs.Root(), float64(cfg.MaxAttachmentBytes)/(1<<20))
// 后台 GC:清掉上传后未随邮件发出的孤立附件,
// 否则取消发信与 Agent 崩溃留下的文件会让磁盘单调增长。
go sweepOrphanAttachments(blobs)
// 日历调度器:把到期的提醒变成邮件。
// 必须在建表(migrate)之后启动 —— 它启动时立即扫一次表。
scheduler.Start()
defer scheduler.Stop()
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
// 必须在 RealIP 之前保存真实 TCP 对端;否则外部请求可伪造
// X-Forwarded-For: 127.0.0.1 绕过 /setup/admin 的本机限制。
r.Use(middleware.CapturePeerAddress)
r.Use(chimw.RealIP)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Agent-Name", "X-Agent-Secret"},
// 附件下载靠 Content-Disposition 拿文件名;不暂存就拿不到。
// Content-Length 给进度条用。
ExposedHeaders: []string{"Link", "Content-Disposition", "Content-Length"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
r.Route("/api/v1", func(r chi.Router) {
// ---- 首次初始化 ----
// status 公开,供登录页判断是否显示初始化向导;真正创建管理员只允许
// Gateway 本机调用。标准部署会在监听前由 bootstrapAdmin 创建管理员,
// 手工初始化则需在服务器上访问 127.0.0.1,LAN/反代请求一律拒绝。
r.Get("/setup/status", handler.SetupStatus)
r.With(middleware.LocalOnly).Post("/setup/admin", handler.SetupAdmin)
// ---- 认证(公开) ----
r.Post("/auth/login", handler.Login)
r.Post("/auth/logout", handler.Logout)
// ---- Agent 注册(凭 secret,非人类登录态) ----
r.Post("/agent/register", handler.RegisterAgent)
// ---- Agent 侧(X-Agent-Name + X-Agent-Secret) ----
r.Group(func(r chi.Router) {
r.Use(middleware.AgentAuth)
r.Post("/agent/heartbeat", handler.HeartbeatAgent)
r.Post("/mail/send", handler.SendMail)
r.Get("/mail/inbox", handler.GetInbox)
// 批量标记已读:不给 mail_ids 就把收件箱全部未读标掉。
// 没有它的话 Agent 每次拉收件箱都会重复捞同一批旧邮件。
r.Post("/mail/read", handler.MarkInboxRead)
r.Post("/mail/{id}/forward", handler.ForwardMail)
r.Post("/permission/request", handler.RequestPermission)
// 附件:先上传拿 id,再在发信时放进 attachment_ids
r.Post("/attachments", handler.UploadAttachment)
r.Get("/attachments/{id}", handler.DownloadAttachment)
// 平台侧会话标题/slug 回写本侧(平台叫什么,本侧就叫什么)
r.Post("/sessions/{id}/sync", handler.SyncSession)
// 邮件场景下的可用模型范围。上报走心跳(agent/heartbeat 的 models 字段),
// 这里只读 —— 给非插件的第三方客户端与排查用。
r.Get("/agent/models/allowed", handler.GetAllowedModels)
// ---- 寻址发现(只读)----
//
// 没有这一组时,send_mail 的 to 是个只能靠记忆拼写的自由文本:
// 想回给抄送方只能从收件箱里拄一段 `opencode@/home.new`,
// 而 `.new` 是一次性的,拄过去只会再建一条会话。
// 人类侧 AddressInput 逐段查 /contacts/suggest 从活数据里选,
// 这一组就是把同一份能力给 Agent。均为只读:
// 归档、改别名、权限决策仍然只有人能做。
r.Get("/agent/contacts", handler.AgentListContacts)
r.Get("/agent/contacts/suggest", handler.AgentSuggestAddress)
r.Get("/agent/mail/{id}", handler.AgentGetMail)
r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread)
r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants)
// ---- 日历 / 待办(可写,但只能动自己建的)----
//
// 在这之前「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout ——
// 进程一重启定时器就消失,提醒静默不见且无处留痕。放进 Gateway 之后
// 由数据库与调度器保证:插件重启、Agent 换机器都不影响。
//
// 三处收紧(见 handler/agent_calendar.go):只看/只改自己建的、
// 不能设给人类、速率 20 次每小时 + 总量 50 条双闸。
r.Post("/agent/calendar/events", handler.AgentCreateCalendarEvent)
r.Get("/agent/calendar/events", handler.AgentListCalendarEvents)
r.Get("/agent/calendar/events/{id}", handler.AgentGetCalendarEvent)
r.Put("/agent/calendar/events/{id}", handler.AgentUpdateCalendarEvent)
r.Delete("/agent/calendar/events/{id}", handler.AgentDeleteCalendarEvent)
})
// ---- 人类登录态 ----
r.Group(func(r chi.Router) {
r.Use(middleware.UserAuth)
r.Get("/auth/me", handler.Me)
r.Post("/auth/password", handler.ChangePassword)
// 自己的邮箱
r.Post("/me/mail/send", handler.MeSendMail)
r.Get("/me/mail/inbox", handler.MeGetInbox)
r.Get("/me/mail/sent", handler.MeGetSent)
r.Get("/me/sessions", handler.MeGetSessions)
r.Post("/me/mail/{id}/forward", handler.MeForwardMail)
// 附件
r.Post("/me/attachments", handler.MeUploadAttachment)
r.Delete("/me/attachments/{id}", handler.MeDeleteAttachment)
// 自己的客户端连接密钥(仅能用于 /me/* 与会话级接口,不可注册 Agent)
// 用户外观(主题 + 壁纸):账号级,跨设备/跨客户端同一份
r.Get("/me/appearance", handler.GetAppearance)
r.Put("/me/appearance", handler.PutAppearance)
r.Post("/me/appearance/image", handler.UploadAppearanceImage)
r.Get("/me/appearance/image", handler.GetAppearanceImage)
r.Delete("/me/appearance/image", handler.DeleteAppearanceImage)
// 设备推送登记(可选通道)。没配推送凭证时也照存:管理员之后把凭证
// 配上就立刻生效,客户端不必重新登记。响应里的 enabled 告诉客户端
// 「服务端现在开没开推送」。
r.Post("/me/devices/push-token", handler.RegisterPushToken)
r.Delete("/me/devices/push-token", handler.DeletePushToken)
r.Get("/me/devices/push-token", handler.ListPushTokens)
r.Post("/me/keys", handler.CreateMyKey)
r.Get("/me/keys", handler.ListMyKeys)
r.Delete("/me/keys/{id}", handler.DeleteMyKey)
// 邮件/会话(带会话级鉴权)
r.Get("/mail/{id}", handler.GetMail)
r.Get("/mail/{id}/thread", handler.GetMailThread)
r.Post("/mail/{id}/read", handler.MarkMailRead)
r.Get("/sessions/{id}", handler.GetSession)
r.Get("/sessions/{id}/mails", handler.GetSessionMails)
r.Put("/sessions/{id}/alias", handler.UpdateSessionAlias)
// 本任务的往返预算:在对话页里随时可改
r.Get("/sessions/{id}/budget", handler.GetSessionBudgetHandler)
r.Put("/sessions/{id}/budget", handler.UpdateSessionBudget)
// 本任务的权限档位:在对话页里随时可改
r.Put("/sessions/{id}/permission", handler.UpdateSessionPermission)
// Agent 在正文里提的改名建议:读取与驳回(接受走上面的 PUT alias)
r.Get("/sessions/{id}/rename-proposal", handler.GetRenameProposal)
r.Post("/sessions/{id}/rename-proposal/dismiss", handler.DismissRenameProposal)
// 联系人 name@path.session
r.Get("/contacts", handler.ListContacts)
r.Get("/contacts/suggest", handler.SuggestAddress)
r.Post("/contacts/archive", handler.ArchiveContact)
// 权限决策
r.Post("/permission/decide", handler.DecidePermission)
r.Get("/permission/pending", handler.ListPendingPermissions)
// 在线 Agent 列表(补全用)
r.Get("/agents", handler.ListAgents)
// 日历事件
r.Post("/calendar/events", handler.CreateCalendarEvent)
r.Get("/calendar/events", handler.ListCalendarEvents)
r.Get("/calendar/events/{id}", handler.GetCalendarEvent)
r.Put("/calendar/events/{id}", handler.UpdateCalendarEvent)
r.Delete("/calendar/events/{id}", handler.DeleteCalendarEvent)
r.Post("/calendar/events/{id}/attachments", handler.UploadCalendarAttachment)
r.Get("/calendar/events/{id}/attachments", handler.ListCalendarAttachments)
// 单条删除:撤一个错传的文件不该要求把整条日程重建
r.Delete("/calendar/attachments/{attachmentID}", handler.DeleteCalendarAttachment)
r.Get("/calendar/export.ics", handler.ExportCalendarICS)
r.Post("/calendar/import.ics", handler.ImportCalendarICS)
/*
* 农历标签(格子用)。单独一个端点而不是塞进 events 响应里:
* 农历是**按日期**的(与有没有事件无关),而 events 只覆盖有事件的那些天 ——
* 塞进去的话,没有事件的日子就没有农历,而日历要求**每个格子**都显示农历。
*
* 换算只在服务端做:两边各写一份天文算法(lunar-go / lunar-javascript)
* 迟早会在某个闰月或朔日上差一天,而那种错表现为**日期错位、不报错**。
*/
r.Get("/calendar/lunar", handler.LunarLabelsHandler)
// 管理员
r.Group(func(r chi.Router) {
r.Use(middleware.AdminOnly)
r.Get("/admin/users", handler.AdminListUsers)
r.Post("/admin/users", handler.AdminCreateUser)
r.Put("/admin/users/{id}", handler.AdminUpdateUser)
r.Delete("/admin/users/{id}", handler.AdminDisableUser)
r.Post("/admin/users/{id}/reset", handler.AdminResetPassword)
r.Get("/admin/scopes", handler.AdminListScopes)
// Agent 接入密钥
r.Post("/admin/agent-keys", handler.CreateAgentKey)
r.Get("/admin/agent-keys", handler.ListAgentKeys)
r.Delete("/admin/agent-keys/{id}", handler.DeleteAgentKey)
r.Post("/admin/agent-keys/{id}/bind", handler.BindAgentKey)
// Agent 发信配额
r.Get("/admin/quotas", handler.AdminListQuotas)
r.Put("/admin/quotas/{name}", handler.AdminSetQuota)
// 邮件场景下每个 Agent 可用的模型范围(勾选平台上报的目录)
r.Get("/admin/agents/{name}/models", handler.AdminListAgentModels)
r.Put("/admin/agents/{name}/models", handler.AdminSetAgentModels)
// 停用 / 恢复一个 Agent。停用是可逆的归档:邮件与会话保留,
// 但从补全里消失、密钥被撤销、重新注册被拒。
// 没有「彻底删除」—— Agent 名与人类用户名共用命名空间,
// 删掉后同名注册者会让历史邮件看起来像是他发的。
r.Put("/admin/agents/{name}/status", handler.AdminSetAgentStatus)
r.Delete("/admin/agents/{name}", handler.AdminDeleteAgent)
})
})
// ---- SSE:Agent 走 header,人类走 Cookie,内部自行分流 ----
r.Get("/events/stream", handler.SSEStream)
r.Get("/events/status", handler.SSEStatus)
// ---- 附件下载:由浏览器直接发起(<a download>),无法带 Authorization 头,
// 因此单独挂在允许 ?access_token= 的中间件下 ----
r.Group(func(r chi.Router) {
r.Use(middleware.UserAuthAllowQueryToken)
r.Get("/me/attachments/{id}", handler.MeDownloadAttachment)
})
})
// 静态前端
//
// 缓存策略见 static/cache.go:入口页必须每次回源校验(否则换了新前端、
// 用户手里的 HTML 仍指向旧一代资源,而且没有任何报错),
// 带内容哈希的 /assets/* 可以永久缓存。
staticRoot := os.Getenv("STATIC_DIR")
var staticMux http.Handler
if staticRoot != "" {
staticMux = http.FileServer(http.Dir(staticRoot))
} else {
staticMux = static.Handler()
}
r.Handle("/assets/*", static.CacheControl(staticMux))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
static.SetIndexCacheControl(w)
w.Write(static.GetIndex())
})
// Web App Manifest(沉浸式全屏的前提:没有它,"添加到主屏幕"打开后
// 仍然是带地址栏的网页)。放在根路径而不是 /assets/ 下,是因为 manifest 里的
// start_url / scope 是相对**manifest 自己的 URL** 解析的:挂在 /assets/ 下就得写
// "../",可读性差且容易被后来者改错。这里显式给一条路由(静态只挂了 / 与 /assets/*)。
r.Get("/manifest.webmanifest", func(w http.ResponseWriter, r *http.Request) {
data, err := static.ReadFile("manifest.webmanifest")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/manifest+json")
// 不缓存:图标/名称/显示模式改动要能立刻生效(它只有几百字节)
w.Header().Set("Cache-Control", "no-cache")
w.Write(data)
})
addr := fmt.Sprintf(":%s", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 0, // SSE 长连接
IdleTimeout: 120 * time.Second,
}
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nShutting down...")
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(c)
}()
fmt.Printf("AgentMail Gateway on %s\n", addr)
fmt.Printf(" Health: http://localhost%s/health\n", addr)
fmt.Printf(" API: http://localhost%s/api/v1\n", addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
fmt.Println("Server stopped")
}
// bootstrapAdmin 首次启动时创建默认管理员
func bootstrapAdmin(ctx context.Context, cfg *config.Config) {
n, err := repo.CountAdmins(ctx)
if err != nil {
log.Fatalf("Failed to count admins: %v", err)
}
if n > 0 {
return
}
pw := cfg.AdminPassword
generated := false
if pw == "" {
pw = repo.RandomPassword(16)
generated = true
}
u, created, err := repo.EnsureAdminUser(ctx, cfg.AdminUser, pw)
if err != nil {
log.Fatalf("Failed to create admin user: %v", err)
}
if created && u != nil {
fmt.Println("========================================")
fmt.Printf(" 已创建默认管理员: %s\n", u.Username)
if generated {
fmt.Printf(" 初始密码(仅本次显示): %s\n", pw)
fmt.Println(" 请登录后立即通过 /auth/password 修改")
} else {
fmt.Println(" 密码来自环境变量 ADMIN_PASSWORD")
}
fmt.Println("========================================")
}
}
// sweepOrphanAttachments 周期清理无人引用的附件。
//
// 两个方向,缺一不可:
//
// 1. **正向**(repo.SweepOrphanAttachments):库里还有记录但从未挂到邮件上。
// 上传与发信是两步,中间放弃(用户取消写信、Agent 崩溃)就会留下这类记录。
// 保留 24 小时再清:足以覆盖一次正常的写信过程。
//
// 2. **反向**(repo.SweepUnreferencedBlobs):磁盘上有文件但库里连记录都没有。
// 一旦记录本身消失(清库、手工 DELETE、迁移),正向那条 SQL 就永远看不见它——
// 本机实测磁盘 8 个 blob 里 7 个属于这种,全部来自一次清库,之后一直占着盘。
//
// 反向清理的年龄下限取得比正向更宽(48 小时):它删的是「库里查无此物」的文件,
// 判据比正向弱,多留一天换取更小的误删面。上传窗口(落盘与入库之间)也靠它兜住。
func sweepOrphanAttachments(blobs *blob.Store) {
const (
interval = 1 * time.Hour
keepFor = 24 * time.Hour
keepUnlinked = 48 * time.Hour
)
sweep := func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 正向失败不能 return:反向那一步与它相互独立,
// 一层坏掉不该让另一层也停工。
sums, err := repo.SweepOrphanAttachments(ctx, keepFor)
if err != nil {
log.Printf("附件 GC 失败: %v", err)
} else {
for _, sum := range sums {
if rErr := blobs.Remove(sum); rErr != nil {
log.Printf("附件 GC 删除 %s 失败: %v", sum[:8], rErr)
}
}
if len(sums) > 0 {
log.Printf("附件 GC 清理了 %d 个孤立记录", len(sums))
}
}
// 反向:库里查无此物的磁盘文件。与正向分开报数——
// 两个数字的含义不同,合成一个会让「哪一层在漏」看不出来。
if n, uErr := repo.SweepUnreferencedBlobs(ctx, blobs, keepUnlinked); uErr != nil {
log.Printf("附件反向 GC 失败: %v", uErr)
} else if n > 0 {
log.Printf("附件反向 GC 清理了 %d 个无引用文件", n)
}
}
// 启动时先扫一遍:上次进程可能是被 kill 掉的,留下的孤立文件不该等一小时
sweep()
for range time.Tick(interval) {
sweep()
}
}