feat(mail): 支持创建自定义文件夹与邮件右键操作

- 新增文件夹创建接口与服务端校验,支持创建自定义文件夹并在列表中使用。
- 为邮件列表补充右键菜单,支持打开、回复、转发、标记、归档、移动、打标签和删除。
- 规则编辑中的“移动到”动作支持自定义文件夹名称输入。
- 补充相关测试,覆盖文件夹创建与邮件移动流程。
This commit is contained in:
LanQin_
2026-06-25 14:11:38 +08:00
parent 07054ca342
commit 7799d5d5b2
6 changed files with 448 additions and 13 deletions
+58
View File
@@ -998,6 +998,55 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
}
}
func TestCustomMailFoldersCreateAndMove(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var bad map[string]any
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "Inbox"}, &bad); code != http.StatusBadRequest {
t.Fatalf("system folder create should be rejected code=%d body=%v", code, bad)
}
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "../bad"}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid folder create should be rejected code=%d body=%v", code, bad)
}
var custom MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
}
var folders struct {
Items []MailFolder `json:"items"`
}
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d msg=%+v", code, sent)
}
var ok map[string]any
if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "客户归档"}, &ok); code != http.StatusOK {
t.Fatalf("move to custom folder code=%d body=%v", code, ok)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/mail/messages?folder="+url.QueryEscape("客户归档"), nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].ID != sent.ID {
t.Fatalf("custom folder messages code=%d items=%+v", code, list.Items)
}
if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "bad/name"}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid move folder should be rejected code=%d body=%v", code, bad)
}
}
func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -3745,6 +3794,15 @@ func containsString(items []string, needle string) bool {
return false
}
func folderListContains(items []MailFolder, name string) bool {
for _, item := range items {
if item.Name == name {
return true
}
}
return false
}
func withoutPermissions(items []string, removed ...string) []string {
removedSet := map[string]bool{}
for _, item := range removed {
+110 -1
View File
@@ -108,6 +108,41 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name, err := normalizeCustomFolderName(req.Name)
if err != nil {
badRequest(w, err)
return
}
if isSystemFolderName(name) {
badRequest(w, errors.New("system folder already exists"))
return
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
}
folder, err := a.folderByID(r.Context(), folderID, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
respondJSON(w, http.StatusCreated, folder)
}
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
@@ -126,6 +161,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
if folder == "" {
folder = "Inbox"
}
if normalized, err := normalizeFolderNameForUser(folder); err != nil {
badRequest(w, err)
return
} else {
folder = normalized
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
@@ -1429,7 +1470,12 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, req.Folder)
folder, err := normalizeFolderNameForUser(req.Folder)
if err != nil {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
@@ -1441,6 +1487,69 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role`, folderID, mailboxID)
var f MailFolder
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
return nil, err
}
return &f, nil
}
func normalizeCustomFolderName(raw string) (string, error) {
name := strings.Join(strings.Fields(raw), " ")
if name == "" {
return "", errors.New("folder name is required")
}
if len([]rune(name)) > 48 {
return "", errors.New("folder name is too long")
}
if strings.ContainsAny(name, `/\:`) || strings.Contains(name, "..") {
return "", errors.New("folder name contains invalid characters")
}
for _, r := range name {
if r < 32 || r == 127 {
return "", errors.New("folder name contains invalid characters")
}
}
return name, nil
}
func normalizeFolderNameForUser(raw string) (string, error) {
name := strings.TrimSpace(raw)
if isSystemFolderName(name) {
switch strings.ToLower(name) {
case "inbox":
return "Inbox", nil
case "sent":
return "Sent", nil
case "drafts":
return "Drafts", nil
case "archive":
return "Archive", nil
case "spam":
return "Spam", nil
case "trash":
return "Trash", nil
}
}
return normalizeCustomFolderName(raw)
}
func isSystemFolderName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "inbox", "sent", "drafts", "archive", "spam", "trash":
return true
default:
return false
}
}
func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
+1
View File
@@ -65,6 +65,7 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAuth)
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder)
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)