feat(mail): 支持创建自定义文件夹与邮件右键操作
- 新增文件夹创建接口与服务端校验,支持创建自定义文件夹并在列表中使用。 - 为邮件列表补充右键菜单,支持打开、回复、转发、标记、归档、移动、打标签和删除。 - 规则编辑中的“移动到”动作支持自定义文件夹名称输入。 - 补充相关测试,覆盖文件夹创建与邮件移动流程。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user