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)
|
||||
|
||||
@@ -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)}` : ""
|
||||
|
||||
+260
-8
@@ -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) => (
|
||||
@@ -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() {
|
||||
</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="选择邮件"
|
||||
|
||||
@@ -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 })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="Inbox">收件箱</SelectItem><SelectItem value="Archive">归档</SelectItem><SelectItem value="Spam">垃圾邮件</SelectItem><SelectItem value="Trash">回收站</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
<SelectItem value="__custom">自定义文件夹</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <Input value="无需填写" readOnly />
|
||||
|
||||
Reference in New Issue
Block a user