From 7720b6b4046bc21d3c65370f478e4c2ef6507789 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 13:54:32 +0800 Subject: [PATCH 1/8] feat: better tag system & fix layout problem when width under 640px --- apps/api/internal/app/mail_handlers.go | 28 +++ apps/api/internal/app/router_auth.go | 1 + apps/web/src/index.css | 26 ++ apps/web/src/lib/api.ts | 1 + apps/web/src/lib/utils.ts | 24 ++ apps/web/src/pages/mail.tsx | 316 ++++++++++++++++--------- 6 files changed, 283 insertions(+), 113 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 61deae9..cca892f 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -216,6 +216,34 @@ func (a *App) handleCreateMailLabel(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, label) } +func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { + mb, err := a.mailboxForCurrentUser(r) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + labelID := chi.URLParam(r, "id") + if labelID == "" { + badRequest(w, fmt.Errorf("label id is required")) + return + } + result, err := a.db.ExecContext(r.Context(), `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete label") + return + } + if n, _ := result.RowsAffected(); n == 0 { + respondError(w, http.StatusNotFound, "label not found") + return + } + labels, err := a.labelsForMailbox(r.Context(), mb.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load labels") + return + } + respondJSON(w, http.StatusOK, map[string]any{"labels": labels}) +} + func (a *App) handleAddMessageLabel(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 31287eb..9672440 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -66,6 +66,7 @@ func (a *App) Router() http.Handler { r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders) 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) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 55ef58a..92b1fa1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -162,3 +162,29 @@ vue-devtools-anchor, [class*="vue-devtools"] { display: none !important; } + +/* Label badge: hide delete button by default, show on hover (desktop) */ +.label-badge-delete { + visibility: hidden; + width: 0; + padding: 0; + overflow: hidden; + transition: visibility 0.1s, width 0.1s, padding 0.1s, opacity 0.15s; +} +.label-badge:hover .label-badge-delete, +.label-badge:focus-within .label-badge-delete { + visibility: visible; + width: 1rem; + padding: 0; + opacity: 1 !important; +} + +/* Touch devices: always show the delete button inside label badges */ +@media (hover: none) and (pointer: coarse) { + .label-badge-delete { + visibility: visible; + width: 1rem; + padding: 0; + opacity: 1 !important; + } +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ae016cf..a56f3ae 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -108,6 +108,7 @@ export const api = { const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : "" return request(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) }) }, + deleteLabel: (id: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}`, { method: "DELETE" }), messages: (folder: string, q = "", cursor = "", mailboxId?: string) => { const params = new URLSearchParams({ folder, q, cursor }) if (mailboxId) params.set("mailboxId", mailboxId) diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index c1002be..e5d456a 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -84,3 +84,27 @@ export function decodeMimeHeader(value: string): string { } }) } + +export interface LabelColorStyle { + /** CSS background-color value (HSL string) */ + backgroundColor: string + /** CSS color value for text — always high contrast against the background */ + color: string +} + +/** + * Generate a deterministic, high-contrast color pair for a label name. + * Uses FNV-1a hash with position-dependent mixing → HSL mapping. + * Text is always white (lightness 45 % guarantees dark-enough background). + */ +export function generateLabelColor(name: string): LabelColorStyle { + // FNV-1a with 32-bit offset basis and prime + let h = 0x811c9dc5 + for (let i = 0; i < name.length; i++) { + h ^= name.charCodeAt(i) + i * 0x01000193 // position-dependent seed + h = Math.imul(h, 0x01000193) + h ^= h >>> 16 + } + const hue = ((h >>> 0) % 360 + 360) % 360 + return { backgroundColor: `hsl(${hue}, 70%, 45%)`, color: "#ffffff" } +} diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index d491ef2..111a3f1 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -12,9 +12,9 @@ import Placeholder from "@tiptap/extension-placeholder" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" -import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" -import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api" -import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime } from "@/lib/utils" +import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" +import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api" +import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" import { Button } from "@/components/ui/button" @@ -104,6 +104,8 @@ export function MailPage() { const [pendingConfirm, setPendingConfirm] = React.useState(null) const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) + const [labelEditMode, setLabelEditMode] = React.useState(false) + const [newLabelEditing, setNewLabelEditing] = React.useState(false) const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) @@ -186,29 +188,66 @@ export function MailPage() { }) const addLabel = useMutation({ mutationFn: ({ id, label }: { id: string; label: MailLabel }) => api.addLabel(id, { name: label.name, color: label.color }), - onSuccess: async (data) => { - if (selectedId) qc.setQueryData(["message", selectedId], (current: MailMessage | undefined) => current ? { ...current, labels: data.labels } : current) - await qc.invalidateQueries({ queryKey: ["messages"] }) - await qc.invalidateQueries({ queryKey: ["labels"] }) + onMutate: async ({ id, label }) => { + await qc.cancelQueries({ queryKey: ["messages"] }) + if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) + const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined + if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: [...(current.labels || []), label] } : current) + qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => m.id === id ? { ...m, labels: [...(m.labels || []), label] } : m) })) } : current) + return { prevMessage } }, - onError: (error) => toast({ title: "添加标签失败", description: error.message }), + onError: (_error, _vars, context) => { + if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) + qc.invalidateQueries({ queryKey: ["messages"] }) + toast({ title: "添加标签失败" }) + }, + onSettled: () => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }, }) const removeLabel = useMutation({ mutationFn: ({ id, labelId }: { id: string; labelId: string }) => api.removeLabel(id, labelId), - onSuccess: async (data) => { - if (selectedId) qc.setQueryData(["message", selectedId], (current: MailMessage | undefined) => current ? { ...current, labels: data.labels } : current) - await qc.invalidateQueries({ queryKey: ["messages"] }) - await qc.invalidateQueries({ queryKey: ["labels"] }) + onMutate: async ({ id, labelId }) => { + await qc.cancelQueries({ queryKey: ["messages"] }) + if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) + const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined + if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: (current.labels || []).filter((l) => l.id !== labelId) } : current) + qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => m.id === id ? { ...m, labels: (m.labels || []).filter((l) => l.id !== labelId) } : m) })) } : current) + return { prevMessage } }, - onError: (error) => toast({ title: "移除标签失败", description: error.message }), + onError: (_error, _vars, context) => { + if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) + qc.invalidateQueries({ queryKey: ["messages"] }) + toast({ title: "移除标签失败" }) + }, + onSettled: () => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }, }) const createLabel = useMutation({ mutationFn: (name: string) => api.createLabel({ mailboxId: selectedMailboxId, name }), - onSuccess: async () => { - await qc.invalidateQueries({ queryKey: ["labels"] }) - toast({ title: "标签已创建" }) + onMutate: async (name) => { + await qc.cancelQueries({ queryKey: ["labels"] }) + const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) + const tempLabel: MailLabel = { id: `temp-${Date.now()}`, name, color: "" } + qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: [...current.items, tempLabel] } : { items: [tempLabel] }) + return { prevLabels } }, - onError: (error) => toast({ title: "创建标签失败", description: error.message }), + onError: (_error, _name, context) => { + if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) + toast({ title: "创建标签失败" }) + }, + onSettled: () => qc.invalidateQueries({ queryKey: ["labels"] }), + }) + const deleteLabel = useMutation({ + mutationFn: (id: string) => api.deleteLabel(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: ["labels"] }) + const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) + qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: current.items.filter((l) => l.id !== id) } : current) + return { prevLabels } + }, + onError: (_error, _id, context) => { + if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) + toast({ title: "删除标签失败" }) + }, + onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) }, }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } }) @@ -615,22 +654,66 @@ export function MailPage() { {(canReadMail || canManageLabels) && - {!sidebarCollapsed && 标签} + {!sidebarCollapsed && ( +
+ 标签 + {canManageLabels && ( +
+ {labelEditMode && ( + + )} + {labelItems.length > 0 && ( + + )} +
+ )} +
+ )} - {canReadMail && labelItems.map((label) => ( - - openLabel(label.id)}> - - {!sidebarCollapsed && {label.name}} - {!sidebarCollapsed && !!label.messageCount && {label.messageCount}} - - - ))} + {canReadMail && labelItems.map((label) => { + const colors = generateLabelColor(label.name) + return ( + + { if (!labelEditMode) openLabel(label.id) }} + > + {sidebarCollapsed ? ( + + ) : ( + + + {label.name} + + )} + {!sidebarCollapsed && !labelEditMode && !!label.messageCount && ( + {label.messageCount} + )} + {!sidebarCollapsed && labelEditMode && canManageLabels && ( + + )} + + + ) + })} {canReadMail && !sidebarCollapsed && !labels.isLoading && labelItems.length === 0 &&
暂无标签
} - {canManageLabels && ( + {canManageLabels && labelEditMode && newLabelEditing && ( - createLabel.mutate(name)} /> + { createLabel.mutate(name); setNewLabelEditing(false) }} editing={newLabelEditing} onEditingChange={setNewLabelEditing} /> )}
@@ -679,7 +762,7 @@ export function MailPage() { ) : isMobile || displayMode === "compact" ? ( : undefined} + icon={mailView === "label" && selectedLabel ? {selectedLabel.name} : undefined} messages={visibleMessages} total={allMessages.length} selectedIds={compactSelectedIds} @@ -723,7 +806,7 @@ export function MailPage() {
toggleCompactSelectAll(value === true)} />
-
{mailView === "label" && selectedLabel && }{viewTitle}
+
{mailView === "label" && selectedLabel && {selectedLabel.name}}{mailView !== "label" && viewTitle}
{selectedCountOnPage > 0 ? `已选 ${selectedCountOnPage} 封` : `${visibleMessages.length} / ${allMessages.length} 封邮件`}
@@ -764,16 +847,10 @@ export function MailPage() { {canOrganizeMail && } - - {canManageLabels && ( - addLabel.mutate({ id: selected.id, label })} - onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })} - pending={addLabel.isPending || removeLabel.isPending} - /> - )} + addLabel.mutate({ id: selected.id, label }), onRemoveLabel: (labelId: string) => removeLabel.mutate({ id: selected.id, labelId }), labelPending: addLabel.isPending || removeLabel.isPending } : {})} + />
@@ -789,7 +866,7 @@ export function MailPage() { return (
- + {isMobile ? (
{!selectedId && ( @@ -806,7 +883,7 @@ export function MailPage() { -
{viewTitle}
+
{mailView === "label" && selectedLabel ? {selectedLabel.name} : viewTitle}
{canSendMail && }
@@ -814,7 +891,7 @@ export function MailPage() {
)} -
{contentView}
+
{contentView}
) : ( @@ -1306,16 +1383,10 @@ function CompactMessageDetail({ }
- - {canManageLabels && ( - onAddLabel(selected, label)} - onRemove={(labelId) => onRemoveLabel(selected, labelId)} - pending={labelPending} - /> - )} + onAddLabel(selected, label), onRemoveLabel: (labelId: string) => onRemoveLabel(selected, labelId), labelPending } : {})} + />
${escapeHtml(selected.bodyText || "")}`) }} /> @@ -1355,7 +1426,7 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang {message.subject} {message.snippet} {scheduled && 已定时} - {visibleLabels.map((label) => )} + {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
@@ -1370,17 +1441,19 @@ function CompactMessageRow({ message, active, checked, scheduled, onCheckedChang ) } -function NewLabelButton({ collapsed, pending, onCreate }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void }) { - const [editing, setEditing] = React.useState(false) +function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void; editing?: boolean; onEditingChange?: (v: boolean) => void }) { + const [internalEditing, setInternalEditing] = React.useState(false) + const isEditing = editing ?? internalEditing + const setEditingState = onEditingChange ?? setInternalEditing const [value, setValue] = React.useState("") if (collapsed) { return ( - setEditing(true)}> + setEditingState(true)}> ) } - if (editing) { + if (isEditing) { return (
- setValue(event.target.value)} onBlur={() => { if (!value.trim()) setEditing(false) }} placeholder="新建标签" disabled={pending} /> + setValue(event.target.value)} onBlur={() => { if (!value.trim()) setEditingState(false) }} placeholder="新建标签" disabled={pending} />
) } return ( - setEditing(true)}> + setEditingState(true)}> 新建标签 @@ -1521,7 +1594,13 @@ function sameMailTime(a?: string, b?: string) { return !Number.isNaN(left) && !Number.isNaN(right) && left === right } -function MessageMetaPanel({ message }: { message: MailMessage }) { +function MessageMetaPanel({ message, availableLabels, onAddLabel, onRemoveLabel, labelPending }: { + message: MailMessage + availableLabels?: MailLabel[] + onAddLabel?: (label: MailLabel) => void + onRemoveLabel?: (labelId: string) => void + labelPending?: boolean +}) { const fromName = senderDisplayName(message) const fromAddress = senderAddress(message) const to = cleanAddressList(message.to) @@ -1529,6 +1608,7 @@ function MessageMetaPanel({ message }: { message: MailMessage }) { const bcc = cleanAddressList(message.bcc) const deliveredTo = extractAddress(message.recipientAddress || message.mailboxAddress || "") const showSentAt = Boolean(message.sentAt) && !sameMailTime(message.sentAt, message.receivedAt) + const labels = message.labels || [] return (
@@ -1569,6 +1649,61 @@ function MessageMetaPanel({ message }: { message: MailMessage }) { {formatDateTime(message.receivedAt)} + {availableLabels && onAddLabel && onRemoveLabel && ( + +
+ {labels.map((label) => { + const colors = generateLabelColor(label.name) + return ( + + + {label.name} + + + ) + })} + + + + + + {availableLabels.length === 0 && 请先在侧栏新建标签} + {availableLabels.map((label) => { + const active = labels.some((l) => l.id === label.id) + const colors = generateLabelColor(label.name) + return ( + { + event.preventDefault() + active ? onRemoveLabel(label.id) : onAddLabel(label) + }} + > + + {label.name} + + ) + })} + + +
+
+ )}
@@ -1648,7 +1783,7 @@ function MessageRow({
{message.subject} {scheduled && 已定时} - {visibleLabels.map((label) => )} + {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && }
@@ -1658,61 +1793,16 @@ function MessageRow({ } -function MailLabelBadge({ label, compact }: { label: MailLabel; compact?: boolean }) { +function MailLabelBadge({ label }: { label: MailLabel }) { + const colors = generateLabelColor(label.name) return ( - + + {label.name} ) } -function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pending }: { messageLabels: MailLabel[]; availableLabels: MailLabel[]; onAdd: (label: MailLabel) => void; onRemove: (labelId: string) => void; pending: boolean }) { - const activeIds = new Set(messageLabels.map((label) => label.id)) - return ( -
-
标签
-
- {messageLabels.map((label) => ( - - {label.name} - - - ))} - {messageLabels.length === 0 && 无标签} - - - - - - {availableLabels.length === 0 && 请先在侧栏新建标签} - {availableLabels.map((label) => ( - { - event.preventDefault() - activeIds.has(label.id) ? onRemove(label.id) : onAdd(label) - }} - > - - {label.name} - - ))} - - -
-
- ) -} - function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const qc = useQueryClient() From 74f456ef7de03c9aaa3ec245ac435e3faaa30dab Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 14:01:38 +0800 Subject: [PATCH 2/8] chore: improve tag delete --- apps/api/internal/app/mail_handlers.go | 19 +++++++++++++++++-- apps/web/src/pages/mail.tsx | 8 ++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index cca892f..d3ff4a1 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -227,7 +227,18 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { badRequest(w, fmt.Errorf("label id is required")) return } - result, err := a.db.ExecContext(r.Context(), `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) + ctx := r.Context() + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to begin transaction") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM message_labels WHERE label_id=?`, labelID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to remove label associations") + return + } + result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete label") return @@ -236,7 +247,11 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "label not found") return } - labels, err := a.labelsForMailbox(r.Context(), mb.ID) + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to commit transaction") + return + } + labels, err := a.labelsForMailbox(ctx, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 111a3f1..31a58fc 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -243,6 +243,14 @@ export function MailPage() { qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: current.items.filter((l) => l.id !== id) } : current) return { prevLabels } }, + onSuccess: (_data, id) => { + if (mailView === "label" && selectedLabelId === id) { + setMailView("folder") + setFolder("Inbox") + setSelectedLabelId("") + setSelectedId(null) + } + }, onError: (_error, _id, context) => { if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) toast({ title: "删除标签失败" }) From 94ab94ecebefbfca9dea18265460a96a9060dd85 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 14:07:53 +0800 Subject: [PATCH 3/8] chore: improve label error handling --- apps/web/src/pages/mail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 31a58fc..22d822a 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -226,7 +226,7 @@ export function MailPage() { await qc.cancelQueries({ queryKey: ["labels"] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) const tempLabel: MailLabel = { id: `temp-${Date.now()}`, name, color: "" } - qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: [...current.items, tempLabel] } : { items: [tempLabel] }) + qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: [...(current.items || []), tempLabel] } : { items: [tempLabel] }) return { prevLabels } }, onError: (_error, _name, context) => { @@ -240,7 +240,7 @@ export function MailPage() { onMutate: async (id) => { await qc.cancelQueries({ queryKey: ["labels"] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) - qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: current.items.filter((l) => l.id !== id) } : current) + qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: (current.items || []).filter((l) => l.id !== id) } : current) return { prevLabels } }, onSuccess: (_data, id) => { From 8d29dbd1267a04e4d02067ba9986e13ad0cd1a0a Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 14:11:11 +0800 Subject: [PATCH 4/8] chore: improve label status handling --- apps/api/internal/app/mail_handlers.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index d3ff4a1..f219d78 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -228,11 +228,15 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { return } ctx := r.Context() - tx, err := a.db.BeginTx(ctx, nil) - if err != nil { - respondError(w, http.StatusInternalServerError, "failed to begin transaction") + if !a.labelBelongsToMailbox(ctx, labelID, mb.ID) { + respondError(w, http.StatusNotFound, "label not found") return } + tx, err := a.db.BeginTx(ctx, nil) + if (_, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ? AND EXISTS (SELECT 1 FROM mail_labels WHERE id = ? AND mailbox_id = ?)", labelID, labelID, mb.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to remove label associations") + return + } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM message_labels WHERE label_id=?`, labelID); err != nil { respondError(w, http.StatusInternalServerError, "failed to remove label associations") From 84cc47e3cf9ea591893a2ff873d1c8f888e48aed Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 19:56:43 +0800 Subject: [PATCH 5/8] chore: revert some wrong changes --- apps/api/internal/app/mail_handlers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index f219d78..ad666d9 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -233,15 +233,15 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { return } tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to begin transaction") + return + } + defer tx.Rollback() if (_, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ? AND EXISTS (SELECT 1 FROM mail_labels WHERE id = ? AND mailbox_id = ?)", labelID, labelID, mb.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to remove label associations") return } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM message_labels WHERE label_id=?`, labelID); err != nil { - respondError(w, http.StatusInternalServerError, "failed to remove label associations") - return - } result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete label") From 77187e76ceeb6946431bcdcfecd9982be34e426f Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 20:12:56 +0800 Subject: [PATCH 6/8] fix: wrong code syntax --- apps/api/internal/app/mail_handlers.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index ad666d9..5076141 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -238,10 +238,10 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if (_, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ? AND EXISTS (SELECT 1 FROM mail_labels WHERE id = ? AND mailbox_id = ?)", labelID, labelID, mb.ID); err != nil { - respondError(w, http.StatusInternalServerError, "failed to remove label associations") - return - } + if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ? AND EXISTS (SELECT 1 FROM mail_labels WHERE id = ? AND mailbox_id = ?)", labelID, labelID, mb.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to remove label associations") + return + } result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete label") From c8dff253cc90cf481dd3d64ada7b1dd0d3663a30 Mon Sep 17 00:00:00 2001 From: killerprojecte Date: Mon, 22 Jun 2026 20:20:44 +0800 Subject: [PATCH 7/8] chore: optimize code --- apps/api/internal/app/mail_handlers.go | 2 +- apps/web/src/pages/mail.tsx | 27 +++++++++++++++----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 5076141..a7f4966 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -238,7 +238,7 @@ func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ? AND EXISTS (SELECT 1 FROM mail_labels WHERE id = ? AND mailbox_id = ?)", labelID, labelID, mb.ID); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ?", labelID); err != nil { respondError(w, http.StatusInternalServerError, "failed to remove label associations") return } diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 22d822a..32e32e5 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -239,9 +239,14 @@ export function MailPage() { mutationFn: (id: string) => api.deleteLabel(id), onMutate: async (id) => { await qc.cancelQueries({ queryKey: ["labels"] }) + await qc.cancelQueries({ queryKey: ["messages"] }) + if (selectedId) await qc.cancelQueries({ queryKey: ["message", selectedId] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId]) + const prevMessage = selectedId ? qc.getQueryData(["message", selectedId]) : undefined qc.setQueryData>(["labels", activeMailboxId], (current) => current ? { ...current, items: (current.items || []).filter((l) => l.id !== id) } : current) - return { prevLabels } + if (selectedId) qc.setQueryData(["message", selectedId], (current) => current ? { ...current, labels: (current.labels || []).filter((l) => l.id !== id) } : current) + qc.setQueriesData>({ queryKey: ["messages"] }, (current) => current ? { ...current, pages: current.pages.map((page) => ({ ...page, items: (page.items || []).map((m) => ({ ...m, labels: (m.labels || []).filter((l) => l.id !== id) })) })) } : current) + return { prevLabels, prevMessage } }, onSuccess: (_data, id) => { if (mailView === "label" && selectedLabelId === id) { @@ -253,6 +258,8 @@ export function MailPage() { }, onError: (_error, _id, context) => { if (context?.prevLabels) qc.setQueryData(["labels", activeMailboxId], context.prevLabels) + if (selectedId && context?.prevMessage) qc.setQueryData(["message", selectedId], context.prevMessage) + qc.invalidateQueries({ queryKey: ["messages"] }) toast({ title: "删除标签失败" }) }, onSettled: () => { qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["messages"] }) }, @@ -667,16 +674,14 @@ export function MailPage() { 标签 {canManageLabels && (
- {labelEditMode && ( - - )} - {labelItems.length > 0 && ( - - )} + + {labelItems.length > 0 && ( + + )}
)} From 0b4b4441c0ee76efd2f43491cf7abf85e0672b21 Mon Sep 17 00:00:00 2001 From: AnserJim <81072191+killerprojecte@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:23:20 +0800 Subject: [PATCH 8/8] chore: use activeMailboxId instead of selectedMailboxId Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/web/src/pages/mail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 32e32e5..2d259bc 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -221,7 +221,7 @@ export function MailPage() { onSettled: () => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }, }) const createLabel = useMutation({ - mutationFn: (name: string) => api.createLabel({ mailboxId: selectedMailboxId, name }), + mutationFn: (name: string) => api.createLabel({ mailboxId: activeMailboxId, name }), onMutate: async (name) => { await qc.cancelQueries({ queryKey: ["labels"] }) const prevLabels = qc.getQueryData>(["labels", activeMailboxId])