diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 21d4968..9828611 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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 { diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 7b45108..da6899c 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -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 { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 59e47ca..6c746fc 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -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) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 9da5f91..3024337 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -116,6 +116,10 @@ export const api = { checkDns: (domainId: string) => request(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), myMailboxes: () => request>("/api/mail/mailboxes"), folders: (mailboxId?: string) => request>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), + createFolder: (payload: { mailboxId?: string; name: string }) => { + const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" + return request(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) }) + }, labels: (mailboxId?: string) => request>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 0542786..65f9e27 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -66,6 +66,7 @@ type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void } +type MessageContextMenuState = { message: MailMessage; x: number; y: number } type MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number } @@ -110,6 +111,8 @@ export function MailPage() { const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [labelEditMode, setLabelEditMode] = React.useState(false) const [newLabelEditing, setNewLabelEditing] = React.useState(false) + const [messageContextMenu, setMessageContextMenu] = React.useState(null) + const [folderDialogOpen, setFolderDialogOpen] = React.useState(false) const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) @@ -283,6 +286,16 @@ export function MailPage() { onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), onSettled: () => setCancelingScheduledId(""), }) + const createFolder = useMutation({ + mutationFn: (name: string) => api.createFolder({ mailboxId: activeMailboxId, name }), + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ["folders"] }) + setFolderDialogOpen(false) + openFolder(created.name) + toast({ title: "文件夹已创建" }) + }, + onError: (error) => toast({ title: "创建文件夹失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) const retrySendQueue = useMutation({ mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id), onMutate: (item) => setSendQueuePendingId(item.id), @@ -628,6 +641,51 @@ export function MailPage() { setMailFilter("all") setMobileSidebarOpen(false) } + function openMessageContextMenu(event: React.MouseEvent, message: MailMessage) { + event.preventDefault() + event.stopPropagation() + if (message.folder !== "Drafts") setSelectedId(message.id) + setMessageContextMenu({ message, x: event.clientX, y: event.clientY }) + } + function closeMessageContextMenu() { + setMessageContextMenu(null) + } + function runMessageContextAction(action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) { + closeMessageContextMenu() + if (action === "open") { + openMessage(message.id) + return + } + if (action === "reply") { + openReply(message) + return + } + if (action === "forward") { + openForward(message) + return + } + if (action === "read") { + markRead.mutate({ id: message.id, read: !message.isRead }) + return + } + if (action === "star") { + star.mutate({ id: message.id, starred: !message.isStarred }) + return + } + if (action === "archive") { + move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" }) + return + } + if (action === "trash") { + move.mutate({ id: message.id, folder: "Trash" }) + return + } + if (action === "spam") { + move.mutate({ id: message.id, folder: "Spam" }) + return + } + confirmDeleteMessage(message) + } function openSendQueue() { setMailView("sendQueue") setSelectedLabelId("") @@ -707,7 +765,16 @@ export function MailPage() { - {!sidebarCollapsed && 邮件夹} + {!sidebarCollapsed && ( +
+ 邮件夹 + {canOrganizeMail && ( + + )} +
+ )} {mailMenuItems.map((item) => ( @@ -881,8 +948,9 @@ export function MailPage() { onAddLabel={(message, label) => addLabel.mutate({ id: message.id, label })} onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })} bulkPending={bulkPending} - onBulkAction={runBulkAction} - canSend={canSendMail} + onBulkAction={runBulkAction} + onContextMenu={openMessageContextMenu} + canSend={canSendMail} canOrganize={canOrganizeMail} canManageLabels={canManageLabels} canDownloadAttachments={canDownloadAttachments} @@ -903,7 +971,7 @@ export function MailPage() { {messages.isLoading && } - {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} + {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onContextMenu={(event) => openMessageContextMenu(event, m)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
} {!messages.isLoading && hasMoreMessages && (
@@ -1037,6 +1105,31 @@ export function MailPage() { events={sendQueueAudit.data?.items || []} onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }} /> + { + closeMessageContextMenu() + move.mutate({ id: message.id, folder: folderName }) + }} + onToggleLabel={(message, label) => { + const active = (message.labels || []).some((item) => item.id === label.id) + active ? removeLabel.mutate({ id: message.id, labelId: label.id }) : addLabel.mutate({ id: message.id, label }) + }} + /> + createFolder.mutate(name)} + /> void; onAction: (action: "open" | "reply" | "forward" | "read" | "star" | "archive" | "trash" | "spam" | "delete", message: MailMessage) => void; onMoveToFolder: (message: MailMessage, folderName: string) => void; onToggleLabel: (message: MailMessage, label: MailLabel) => void }) { + React.useEffect(() => { + if (!state) return + const close = () => onClose() + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose() + } + window.addEventListener("pointerdown", close) + window.addEventListener("resize", close) + window.addEventListener("scroll", close, true) + window.addEventListener("keydown", onKeyDown) + return () => { + window.removeEventListener("pointerdown", close) + window.removeEventListener("resize", close) + window.removeEventListener("scroll", close, true) + window.removeEventListener("keydown", onKeyDown) + } + }, [state, onClose]) + + if (!state) return null + const { message } = state + const position = contextMenuPosition(state.x, state.y) + const draft = message.folder === "Drafts" + const itemClass = "flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent focus:bg-accent disabled:pointer-events-none disabled:opacity-50" + + function item(label: string, icon: React.ReactNode, action: Parameters[0], destructive = false) { + return ( + + ) + } + function toggleLabel(label: MailLabel) { + onToggleLabel(message, label) + onClose() + } + function moveToFolder(folderName: string) { + onMoveToFolder(message, folderName) + onClose() + } + const movableFolders = folders.filter((folder) => folder.name !== message.folder && folder.name !== "Drafts") + + return ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + role="menu" + > + {item(draft ? "编辑草稿" : "打开邮件", draft ? : , "open")} + {!draft && canSend && ( + <> + {item("回复", , "reply")} + {item("转发", , "forward")} + + )} + {!draft && canOrganize && ( + <> +
+ {item(message.isRead ? "标为未读" : "标为已读", , "read")} + {item(message.isStarred ? "取消星标" : "添加星标", , "star")} + {item(message.folder === "Archive" ? "取消归档" : "归档", , "archive")} + {message.folder !== "Trash" && item("移入回收站", , "trash")} + {message.folder !== "Spam" && item("移入垃圾邮件", , "spam")} + + )} + {!draft && canOrganize && movableFolders.length > 0 && ( + <> +
+
移动到
+
+ {movableFolders.map((folder) => ( + + ))} +
+ + )} + {canManageLabels && labels.length > 0 && ( + <> +
+
标签
+
+ {labels.map((label) => { + const active = (message.labels || []).some((item) => item.id === label.id) + const colors = generateLabelColor(label.name) + return ( + + ) + })} +
+ + )} + {canOrganize && ( + <> +
+ {item("删除", , "delete", true)} + + )} +
+ ) +} + +function contextMenuPosition(x: number, y: number) { + const width = 208 + const height = 312 + const padding = 8 + const maxX = Math.max(padding, window.innerWidth - width - padding) + const maxY = Math.max(padding, window.innerHeight - height - padding) + return { x: Math.min(Math.max(x, padding), maxX), y: Math.min(Math.max(y, padding), maxY) } +} + +function CreateFolderDialog({ open, pending, onOpenChange, onCreate }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onCreate: (name: string) => void }) { + const [name, setName] = React.useState("") + React.useEffect(() => { + if (open) setName("") + }, [open]) + const trimmed = name.trim() + return ( + + + + 新建文件夹 + +
{ + event.preventDefault() + if (trimmed) onCreate(trimmed) + }} + > +
+ + setName(event.target.value)} placeholder="例如:客户、账单、项目归档" /> +
+ + + + +
+
+
+ ) +} + function CompactMailView({ title, icon, @@ -1431,6 +1679,7 @@ function CompactMailView({ onRemoveLabel, bulkPending, onBulkAction, + onContextMenu, canSend, canOrganize, canManageLabels, @@ -1468,6 +1717,7 @@ function CompactMailView({ onRemoveLabel: (message: MailMessage, labelId: string) => void bulkPending: boolean onBulkAction: (action: BulkAction) => void + onContextMenu: (event: React.MouseEvent, message: MailMessage) => void canSend: boolean canOrganize: boolean canManageLabels: boolean @@ -1527,7 +1777,7 @@ function CompactMailView({
{loading && } - {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} canOrganize={canOrganize} />)} + {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onContextMenu={(event) => onContextMenu(event, message)} onStar={() => onStar(message)} canOrganize={canOrganize} />)} {!loading && messages.length === 0 &&
{emptyMessage}
} {!loading && hasMore && (
@@ -1728,12 +1978,12 @@ function MailHtmlFrame({ message }: { message: MailMessage }) { ) } -function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void; canOrganize: boolean }) { +function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onContextMenu, onStar, canOrganize }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onContextMenu: (event: React.MouseEvent) => void; onStar: () => void; canOrganize: boolean }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) return ( -
+
onCheckedChange(value === true)} onClick={(event) => event.stopPropagation()} className="mt-0.5 shrink-0 sm:mt-0" /> {message.isRead ? ( @@ -2068,6 +2318,7 @@ function MessageRow({ scheduled, onCheckedChange, onClick, + onContextMenu, onStar, canOrganize, }: { @@ -2077,13 +2328,14 @@ function MessageRow({ scheduled?: boolean onCheckedChange: (checked: boolean) => void onClick: () => void + onContextMenu: (event: React.MouseEvent) => void onStar: () => void canOrganize: boolean }) { const visibleLabels = (message.labels || []).slice(0, 2) const hiddenLabelCount = Math.max((message.labels?.length || 0) - visibleLabels.length, 0) const senderName = senderDisplayName(message) - return
+ return
= { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" } function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) { @@ -982,11 +983,21 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction; return onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" /> } if (action.type === "move") { + const value = action.value || "Archive" return ( - +
+ + onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" /> +
) } return