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)
+4
View File
@@ -116,6 +116,10 @@ export const api = {
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
},
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
+258 -6
View File
@@ -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<MessageContextMenuState | null>(null)
const [folderDialogOpen, setFolderDialogOpen] = React.useState(false)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const themeMountedRef = React.useRef(false)
const mailNotifyStateRef = React.useRef<Record<string, MailNotificationState>>({})
@@ -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() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
{!sidebarCollapsed && (
<div className="flex items-center justify-between px-2 py-1.5">
<SidebarGroupLabel className="m-0 p-0"></SidebarGroupLabel>
{canOrganizeMail && (
<Button type="button" variant="ghost" size="icon" className="h-5 w-5" onClick={() => setFolderDialogOpen(true)} disabled={!activeMailboxId}>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
<SidebarGroupContent>
<SidebarMenu>
{mailMenuItems.map((item) => (
@@ -882,6 +949,7 @@ export function MailPage() {
onRemoveLabel={(message, labelId) => removeLabel.mutate({ id: message.id, labelId })}
bulkPending={bulkPending}
onBulkAction={runBulkAction}
onContextMenu={openMessageContextMenu}
canSend={canSendMail}
canOrganize={canOrganizeMail}
canManageLabels={canManageLabels}
@@ -903,7 +971,7 @@ export function MailPage() {
</div>
<ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} canOrganize={canOrganizeMail} />)}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} checked={compactSelectedIds.includes(m.id)} scheduled={scheduledDraftIds.has(m.id)} onCheckedChange={(checked) => 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 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!messages.isLoading && hasMoreMessages && (
<div className="border-b p-4 text-center">
@@ -1037,6 +1105,31 @@ export function MailPage() {
events={sendQueueAudit.data?.items || []}
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
/>
<MessageContextMenu
state={messageContextMenu}
labels={labelItems}
folders={folders.data?.items || []}
canSend={canSendMail}
canOrganize={canOrganizeMail}
canManageLabels={canManageLabels}
labelPending={addLabel.isPending || removeLabel.isPending}
onClose={closeMessageContextMenu}
onAction={runMessageContextAction}
onMoveToFolder={(message, folderName) => {
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 })
}}
/>
<CreateFolderDialog
open={folderDialogOpen}
pending={createFolder.isPending}
onOpenChange={setFolderDialogOpen}
onCreate={(name) => createFolder.mutate(name)}
/>
<ConfirmDialog
open={!!pendingConfirm}
title={pendingConfirm?.title || ""}
@@ -1398,6 +1491,161 @@ function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (ac
)
}
function MessageContextMenu({ state, labels, folders, canSend, canOrganize, canManageLabels, labelPending, onClose, onAction, onMoveToFolder, onToggleLabel }: { state: MessageContextMenuState | null; labels: MailLabel[]; folders: MailFolder[]; canSend: boolean; canOrganize: boolean; canManageLabels: boolean; labelPending: boolean; onClose: () => 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<typeof onAction>[0], destructive = false) {
return (
<button type="button" className={cn(itemClass, destructive && "text-destructive hover:text-destructive")} onClick={() => onAction(action, message)}>
{icon}
<span>{label}</span>
</button>
)
}
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 (
<div
className="fixed z-[110] w-52 rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{ left: position.x, top: position.y }}
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
role="menu"
>
{item(draft ? "编辑草稿" : "打开邮件", draft ? <PencilLine className="h-4 w-4" /> : <Mail className="h-4 w-4" />, "open")}
{!draft && canSend && (
<>
{item("回复", <Reply className="h-4 w-4" />, "reply")}
{item("转发", <Forward className="h-4 w-4" />, "forward")}
</>
)}
{!draft && canOrganize && (
<>
<div className="-mx-1 my-1 h-px bg-border" />
{item(message.isRead ? "标为未读" : "标为已读", <MailCheck className="h-4 w-4" />, "read")}
{item(message.isStarred ? "取消星标" : "添加星标", <Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} />, "star")}
{item(message.folder === "Archive" ? "取消归档" : "归档", <Archive className="h-4 w-4" />, "archive")}
{message.folder !== "Trash" && item("移入回收站", <Trash2 className="h-4 w-4" />, "trash")}
{message.folder !== "Spam" && item("移入垃圾邮件", <Trash2 className="h-4 w-4" />, "spam")}
</>
)}
{!draft && canOrganize && movableFolders.length > 0 && (
<>
<div className="-mx-1 my-1 h-px bg-border" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"></div>
<div className="max-h-44 overflow-y-auto">
{movableFolders.map((folder) => (
<button key={folder.id} type="button" className={itemClass} onClick={() => moveToFolder(folder.name)}>
{folderIcons[folder.role] || <Inbox className="h-4 w-4" />}
<span className="min-w-0 flex-1 truncate text-left">{folderLabels[folder.name] || folder.name}</span>
{folder.totalCount > 0 && <span className="text-xs text-muted-foreground">{folder.totalCount}</span>}
</button>
))}
</div>
</>
)}
{canManageLabels && labels.length > 0 && (
<>
<div className="-mx-1 my-1 h-px bg-border" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"></div>
<div className="max-h-44 overflow-y-auto">
{labels.map((label) => {
const active = (message.labels || []).some((item) => item.id === label.id)
const colors = generateLabelColor(label.name)
return (
<button key={label.id} type="button" className={itemClass} disabled={labelPending} onClick={() => toggleLabel(label)}>
<Check className={cn("h-4 w-4", active ? "opacity-100" : "opacity-0")} />
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: colors.backgroundColor }} />
<span className="min-w-0 flex-1 truncate text-left">{active ? `移除 ${label.name}` : label.name}</span>
</button>
)
})}
</div>
</>
)}
{canOrganize && (
<>
<div className="-mx-1 my-1 h-px bg-border" />
{item("删除", <Trash2 className="h-4 w-4" />, "delete", true)}
</>
)}
</div>
)
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,28rem)] max-w-none">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (trimmed) onCreate(trimmed)
}}
>
<div className="space-y-2">
<Label htmlFor="new-folder-name"></Label>
<Input id="new-folder-name" autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:客户、账单、项目归档" />
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={!trimmed || pending}>{pending ? "创建中..." : "创建"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
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({
</div>
<ScrollArea className="min-h-0 flex-1">
{loading && <MessageSkeleton />}
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} scheduled={scheduledDraftIds.has(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} canOrganize={canOrganize} />)}
{messages.map((message) => <CompactMessageRow key={message.id} message={message} active={selectedId === message.id} checked={selectedIds.includes(message.id)} scheduled={scheduledDraftIds.has(message.id)} onCheckedChange={(checked) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onContextMenu={(event) => onContextMenu(event, message)} onStar={() => onStar(message)} canOrganize={canOrganize} />)}
{!loading && messages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
{!loading && hasMore && (
<div className="border-b p-4 text-center">
@@ -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 (
<div onClick={onClick} className={cn("cursor-pointer border-b px-3 py-3 text-sm transition-colors hover:bg-accent/50 sm:grid sm:grid-cols-[32px_28px_minmax(140px,220px)_minmax(0,1fr)_104px_36px] sm:items-center sm:gap-2 sm:px-4 sm:py-2", active && "bg-accent", !message.isRead && "font-semibold")}>
<div onClick={onClick} onContextMenu={onContextMenu} className={cn("cursor-pointer border-b px-3 py-3 text-sm transition-colors hover:bg-accent/50 sm:grid sm:grid-cols-[32px_28px_minmax(140px,220px)_minmax(0,1fr)_104px_36px] sm:items-center sm:gap-2 sm:px-4 sm:py-2", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="flex gap-3 sm:contents">
<Checkbox aria-label="选择邮件" checked={checked} onCheckedChange={(value) => 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 <div onClick={onClick} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
return <div onClick={onClick} onContextMenu={onContextMenu} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="flex gap-3">
<Checkbox
aria-label="选择邮件"
+13 -2
View File
@@ -820,6 +820,7 @@ const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contai
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"]
const ruleActionLabels: Record<MailRuleAction["type"], string> = { 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 <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
}
if (action.type === "move") {
const value = action.value || "Archive"
return (
<Select value={action.value || "Archive"} onValueChange={(value) => onChange({ value })}>
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Inbox"></SelectItem><SelectItem value="Archive"></SelectItem><SelectItem value="Spam"></SelectItem><SelectItem value="Trash"></SelectItem></SelectContent>
<SelectContent>
<SelectItem value="Inbox"></SelectItem>
<SelectItem value="Archive"></SelectItem>
<SelectItem value="Spam"></SelectItem>
<SelectItem value="Trash"></SelectItem>
<SelectItem value="__custom"></SelectItem>
</SelectContent>
</Select>
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
</div>
)
}
return <Input value="无需填写" readOnly />