feat(mail): 支持邮件标签与富文本写信

- 新增标签数据结构、接口与路由,支持创建/删除邮件标签、按标签筛选邮件和查看星标邮件。
- 邮件详情与列表补充标签展示,发送/移动/删除/已读等操作同步刷新相关数据。
- 前端重构邮件页,加入标签侧栏、标签管理弹层,并将写信编辑器升级为 Markdown 富文本。
- 顺带优化登录态超时处理与部分导航入口,调整前端依赖版本。
This commit is contained in:
LanQin_
2026-06-15 17:37:40 +08:00
parent 1f964eefe2
commit 96822486af
14 changed files with 1398 additions and 1253 deletions
+17
View File
@@ -253,9 +253,26 @@ func (a *App) migrate(ctx context.Context) error {
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(user_id, mailbox_id, email) UNIQUE(user_id, mailbox_id, email)
)`, )`,
`CREATE TABLE IF NOT EXISTS mail_labels (
id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#64748b',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(mailbox_id, name)
)`,
`CREATE TABLE IF NOT EXISTS message_labels (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES mail_labels(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(message_id, label_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id, email)`, `CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id, email)`,
`CREATE INDEX IF NOT EXISTS idx_mail_rules_user_mailbox ON mail_rules(user_id, mailbox_id, enabled)`, `CREATE INDEX IF NOT EXISTS idx_mail_rules_user_mailbox ON mail_rules(user_id, mailbox_id, enabled)`,
`CREATE INDEX IF NOT EXISTS idx_blocked_senders_user_mailbox ON blocked_senders(user_id, mailbox_id, email)`, `CREATE INDEX IF NOT EXISTS idx_blocked_senders_user_mailbox ON blocked_senders(user_id, mailbox_id, email)`,
`CREATE INDEX IF NOT EXISTS idx_mail_labels_mailbox ON mail_labels(mailbox_id, name)`,
`CREATE INDEX IF NOT EXISTS idx_message_labels_label ON message_labels(label_id, message_id)`,
} }
for _, stmt := range stmts { for _, stmt := range stmts {
if _, err := a.db.ExecContext(ctx, stmt); err != nil { if _, err := a.db.ExecContext(ctx, stmt); err != nil {
+27
View File
@@ -226,6 +226,33 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/move", map[string]string{"folder": "Archive"}, &ok); code != http.StatusOK { if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/move", map[string]string{"folder": "Archive"}, &ok); code != http.StatusOK {
t.Fatalf("move code=%d", code) t.Fatalf("move code=%d", code)
} }
var labelUpdate struct {
Labels []MailLabel `json:"labels"`
}
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/labels", map[string]string{"name": "重要"}, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 1 {
t.Fatalf("add label code=%d labels=%+v", code, labelUpdate.Labels)
}
var labels struct {
Items []MailLabel `json:"items"`
}
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 {
t.Fatalf("labels code=%d items=%+v", code, labels.Items)
}
var labeled struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels)
}
var starred struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/starred", nil, &starred); code != http.StatusOK || len(starred.Items) != 1 || starred.Items[0].ID != detail.ID || starred.Items[0].Folder != "Archive" {
t.Fatalf("starred view code=%d items=%+v", code, starred.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID, nil, &ok); code != http.StatusOK { if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete code=%d", code) t.Fatalf("delete code=%d", code)
} }
+260 -9
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
@@ -102,6 +103,14 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
if !a.labelBelongsToMailbox(r.Context(), labelID, mb.ID) {
respondError(w, http.StatusNotFound, "label not found")
return
}
a.respondMailMessageList(w, r, `m.mailbox_id=? AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{mb.ID, labelID})
return
}
folder := r.URL.Query().Get("folder") folder := r.URL.Query().Get("folder")
if folder == "" { if folder == "" {
folder = "Inbox" folder = "Inbox"
@@ -111,6 +120,19 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load folder") respondError(w, http.StatusInternalServerError, "failed to load folder")
return return
} }
a.respondMailMessageList(w, r, `m.mailbox_id=? AND m.folder_id=?`, []any{mb.ID, folderID})
}
func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
a.respondMailMessageList(w, r, `m.mailbox_id=? AND m.is_starred=1`, []any{mb.ID})
}
func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, where string, args []any) {
q := strings.TrimSpace(r.URL.Query().Get("q")) q := strings.TrimSpace(r.URL.Query().Get("q"))
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor")) offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 { if offset < 0 {
@@ -118,16 +140,14 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
} }
limit := 30 limit := 30
args := []any{mb.ID, folderID}
where := `mailbox_id=? AND folder_id=?`
if q != "" { if q != "" {
where += ` AND (subject LIKE ? OR from_addr LIKE ? OR snippet LIKE ? OR body_text LIKE ?)` where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
like := "%" + q + "%" like := "%" + q + "%"
args = append(args, like, like, like, like) args = append(args, like, like, like, like)
} }
args = append(args, limit+1, offset) args = append(args, limit+1, offset)
query := `SELECT id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,is_read,is_starred,has_attachments,size_bytes query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages WHERE ` + where + ` ORDER BY received_at DESC LIMIT ? OFFSET ?` FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?`
rows, err := a.db.QueryContext(r.Context(), query, args...) rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load messages") respondError(w, http.StatusInternalServerError, "failed to load messages")
@@ -136,7 +156,7 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
items := []MailMessage{} items := []MailMessage{}
for rows.Next() { for rows.Next() {
msg, err := scanMessageSummary(rows, folder) msg, err := scanMessageSummary(rows)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan messages") respondError(w, http.StatusInternalServerError, "failed to scan messages")
return return
@@ -148,9 +168,104 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
items = items[:limit] items = items[:limit]
next = strconv.Itoa(offset + limit) next = strconv.Itoa(offset + limit)
} }
if err := a.attachLabelsToMessages(r.Context(), items); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load labels")
return
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
} }
func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox 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{"items": labels})
}
func (a *App) handleCreateMailLabel(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"`
Color string `json:"color"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
label, err := a.ensureLabel(r.Context(), mb.ID, req.Name, req.Color)
if err != nil {
badRequest(w, err)
return
}
respondJSON(w, http.StatusCreated, label)
}
func (a *App) handleAddMessageLabel(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
label, err := a.ensureLabel(r.Context(), msg.MailboxID, req.Name, req.Color)
if err != nil {
badRequest(w, err)
return
}
_, err = a.db.ExecContext(r.Context(), `INSERT OR IGNORE INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?)`, msg.ID, label.ID, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to add label")
return
}
labels, err := a.labelsForMessage(r.Context(), msg.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) handleRemoveMessageLabel(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
labelID := strings.TrimSpace(chi.URLParam(r, "labelID"))
if !a.labelBelongsToMailbox(r.Context(), labelID, msg.MailboxID) {
respondError(w, http.StatusNotFound, "label not found")
return
}
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM message_labels WHERE message_id=? AND label_id=?`, msg.ID, labelID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to remove label")
return
}
labels, err := a.labelsForMessage(r.Context(), msg.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) handleMailMessage(w http.ResponseWriter, r *http.Request) { func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true) msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil { if err != nil {
@@ -509,6 +624,11 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma
} }
msg.Attachments = atts msg.Attachments = atts
} }
labels, err := a.labelsForMessage(ctx, id)
if err != nil {
return nil, err
}
msg.Labels = labels
return &msg, nil return &msg, nil
} }
@@ -588,6 +708,138 @@ func (a *App) attachmentsForMessage(ctx context.Context, messageID string) ([]At
return items, nil return items, nil
} }
func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLabel, error) {
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id
WHERE l.mailbox_id=?
GROUP BY l.id,l.mailbox_id,l.name,l.color
ORDER BY lower(l.name)`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []MailLabel{}
for rows.Next() {
var item MailLabel
if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color, &item.MessageCount); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLabel, error) {
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
WHERE ml.message_id=?
ORDER BY lower(l.name)`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []MailLabel{}
for rows.Next() {
var item MailLabel
if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) error {
if len(items) == 0 {
return nil
}
ids := make([]string, 0, len(items))
index := make(map[string]int, len(items))
args := make([]any, 0, len(items))
for i := range items {
ids = append(ids, "?")
index[items[i].ID] = i
args = append(args, items[i].ID)
}
rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color
FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id
WHERE ml.message_id IN (`+strings.Join(ids, ",")+`)
ORDER BY lower(l.name)`, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var messageID string
var label MailLabel
if err := rows.Scan(&messageID, &label.ID, &label.MailboxID, &label.Name, &label.Color); err != nil {
return err
}
if itemIndex, ok := index[messageID]; ok {
items[itemIndex].Labels = append(items[itemIndex].Labels, label)
}
}
return rows.Err()
}
func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) {
name = normalizeLabelName(name)
if name == "" {
return MailLabel{}, errors.New("label name is required")
}
color = normalizeLabelColor(color)
now := a.now().UTC().Format(time.RFC3339Nano)
var existing MailLabel
row := a.db.QueryRowContext(ctx, `SELECT id,mailbox_id,name,color FROM mail_labels WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name)
if err := row.Scan(&existing.ID, &existing.MailboxID, &existing.Name, &existing.Color); err == nil {
if color != "" && color != existing.Color {
if _, err := a.db.ExecContext(ctx, `UPDATE mail_labels SET color=?, updated_at=? WHERE id=?`, color, now, existing.ID); err != nil {
return MailLabel{}, err
}
existing.Color = color
}
return existing, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return MailLabel{}, err
}
id := newID("lbl")
if color == "" {
color = "#64748b"
}
_, err := a.db.ExecContext(ctx, `INSERT INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, mailboxID, name, color, now, now)
if err != nil {
return MailLabel{}, err
}
return MailLabel{ID: id, MailboxID: mailboxID, Name: name, Color: color}, nil
}
func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID string) bool {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mailboxID).Scan(&count)
return count > 0
}
func normalizeLabelName(name string) string {
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
if len([]rune(name)) > 32 {
name = string([]rune(name)[:32])
}
return name
}
func normalizeLabelColor(color string) string {
color = strings.TrimSpace(color)
if len(color) != 7 || !strings.HasPrefix(color, "#") {
return ""
}
for _, r := range color[1:] {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return ""
}
}
return strings.ToLower(color)
}
func (a *App) deleteMessageFiles(ctx context.Context, messageID string) { func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
rows, err := a.db.QueryContext(ctx, `SELECT storage_path FROM attachments WHERE message_id=?`, messageID) rows, err := a.db.QueryContext(ctx, `SELECT storage_path FROM attachments WHERE message_id=?`, messageID)
if err != nil { if err != nil {
@@ -619,15 +871,14 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
return msg, nil return msg, nil
} }
func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) { func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
if err != nil { if err != nil {
return msg, err return msg, err
} }
msg.Folder = folder
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
+5
View File
@@ -56,11 +56,16 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAuth) r.Use(a.requireAuth)
r.Get("/mail/mailboxes", a.handleMyMailboxes) r.Get("/mail/mailboxes", a.handleMyMailboxes)
r.Get("/mail/folders", a.handleMailFolders) r.Get("/mail/folders", a.handleMailFolders)
r.Get("/mail/labels", a.handleMailLabels)
r.Post("/mail/labels", a.handleCreateMailLabel)
r.Get("/mail/messages", a.handleMailMessages) r.Get("/mail/messages", a.handleMailMessages)
r.Get("/mail/starred", a.handleStarredMessages)
r.Get("/mail/messages/{id}", a.handleMailMessage) r.Get("/mail/messages/{id}", a.handleMailMessage)
r.Post("/mail/send", a.handleMailSend) r.Post("/mail/send", a.handleMailSend)
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead) r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.Post("/mail/messages/{id}/star", a.handleStar) r.Post("/mail/messages/{id}/star", a.handleStar)
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.Post("/mail/messages/{id}/move", a.handleMove) r.Post("/mail/messages/{id}/move", a.handleMove)
r.Delete("/mail/messages/{id}", a.handleDeleteMessage) r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.Get("/mail/attachments/{id}", a.handleAttachment) r.Get("/mail/attachments/{id}", a.handleAttachment)
+9
View File
@@ -59,6 +59,14 @@ type MailFolder struct {
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
} }
type MailLabel struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
Name string `json:"name"`
Color string `json:"color"`
MessageCount int `json:"messageCount,omitempty"`
}
type MailMessage struct { type MailMessage struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"` MailboxID string `json:"mailboxId,omitempty"`
@@ -83,6 +91,7 @@ type MailMessage struct {
IsStarred bool `json:"isStarred"` IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"` HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"` Attachments []Attachment `json:"attachments,omitempty"`
} }
+597 -1178
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -25,24 +25,25 @@
"@tanstack/react-query": "5.59.16", "@tanstack/react-query": "5.59.16",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"dompurify": "3.1.7", "dompurify": "3.4.10",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"marked": "18.0.5",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-router-dom": "6.28.0", "react-router-dom": "6.30.4",
"tailwind-merge": "2.5.4" "tailwind-merge": "2.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.10.1", "@types/node": "24.13.2",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.3", "@vitejs/plugin-react": "6.0.2",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"postcss": "8.4.49", "postcss": "8.5.15",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "5.4.11" "vite": "8.0.16"
} }
} }
+22 -5
View File
@@ -39,11 +39,12 @@ export function ProtectedLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
if (me.isLoading) return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div> if (me.isLoading) return <AuthLoading />
if (me.isError && me.error.message.includes("请求超时")) return <AuthError message={me.error.message} onRetry={() => me.refetch()} />
if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} /> if (me.isError || !me.data?.user) return <Navigate to="/login" replace state={{ from: location.pathname }} />
const user = me.data.user const user = me.data.user
const isMailRoute = location.pathname.startsWith("/mail") const isMailRoute = location.pathname === "/" || location.pathname.startsWith("/mail")
const isProfileRoute = location.pathname.startsWith("/profile") const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin") const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview" const adminSection = new URLSearchParams(location.search).get("section") || "overview"
@@ -63,9 +64,9 @@ export function ProtectedLayout() {
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link to="/mail"> <Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Mail className="size-4" /> <Mail className="size-4" />
</div> </div>
@@ -137,3 +138,19 @@ export function ProtectedLayout() {
</SidebarProvider> </SidebarProvider>
) )
} }
function AuthLoading() {
return <div className="grid min-h-screen place-items-center text-muted-foreground">...</div>
}
function AuthError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-sm font-medium"></div>
<div className="text-sm text-muted-foreground">{message}</div>
<Button type="button" variant="outline" onClick={onRetry}></Button>
</div>
</div>
)
}
+1 -1
View File
@@ -2,5 +2,5 @@ import { useQuery } from "@tanstack/react-query"
import { api } from "@/lib/api" import { api } from "@/lib/api"
export function useMe() { export function useMe() {
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false }) return useQuery({ queryKey: ["me"], queryFn: api.me, retry: 1 })
} }
+43 -6
View File
@@ -6,8 +6,10 @@ export type Mailbox = { id: string; userId: string; userEmail?: string; domainId
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number } export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = { export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[] id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
} }
export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> } export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
@@ -44,14 +46,32 @@ export type PublicSettings = { turnstileEnabled: boolean; turnstileSiteKey: stri
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
const REQUEST_TIMEOUT_MS = 15_000
async function request<T>(path: string, init: RequestInit = {}): Promise<T> { async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init }) const controller = new AbortController()
if (!res.ok) { const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let message = `${res.status} ${res.statusText}` const externalSignal = init.signal
try { const body = await res.json(); message = body.error || message } catch {} if (externalSignal) {
throw new Error(message) if (externalSignal.aborted) controller.abort()
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true })
}
try {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init, signal: controller.signal })
if (!res.ok) {
let message = `${res.status} ${res.statusText}`
try { const body = await res.json(); message = body.error || message } catch {}
throw new Error(message)
}
return res.json() as Promise<T>
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("请求超时,请检查后端服务是否正常")
}
throw error instanceof Error ? error : new Error("网络请求失败")
} finally {
window.clearTimeout(timeout)
} }
return res.json() as Promise<T>
} }
export const api = { export const api = {
@@ -113,15 +133,32 @@ export const api = {
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"), myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
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)}` : ""
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
},
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => { messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, q, cursor }) const params = new URLSearchParams({ folder, q, cursor })
if (mailboxId) params.set("mailboxId", mailboxId) if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`) return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
}, },
labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ labelId, q, cursor })
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
},
starredMessages: (q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ q, cursor })
if (mailboxId) params.set("mailboxId", mailboxId)
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
message: (id: string) => request<MailMessage>(`/api/mail/messages/${id}`), message: (id: string) => request<MailMessage>(`/api/mail/messages/${id}`),
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload) }), send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload) }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
star: (id: string, starred: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/star`, { method: "POST", body: JSON.stringify({ starred }) }), star: (id: string, starred: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/star`, { method: "POST", body: JSON.stringify({ starred }) }),
addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }),
removeLabel: (id: string, labelID: string) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels/${labelID}`, { method: "DELETE" }),
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }), move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
} }
+4 -3
View File
@@ -15,8 +15,9 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWind
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> }, { path: "/login", element: <LoginPage /> },
{ path: "/", element: <ProtectedLayout />, children: [ { path: "/", element: <ProtectedLayout />, children: [
{ index: true, element: <Navigate to="/mail" replace /> }, { index: true, element: <MailPage /> },
{ path: "mail", element: <MailPage /> }, { path: "mail", element: <Navigate to="/" replace /> },
{ path: "mail/starred", element: <Navigate to="/" replace /> },
{ path: "profile", element: <ProfilePage /> }, { path: "profile", element: <ProfilePage /> },
{ path: "admin", element: <AdminOnly><AdminPage /></AdminOnly> }, { path: "admin", element: <AdminOnly><AdminPage /></AdminOnly> },
] }, ] },
@@ -26,7 +27,7 @@ function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe() const me = useMe()
if (me.isLoading) return null if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace /> if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/mail" replace /> if (me.data.user.role !== "admin") return <Navigate to="/" replace />
return <>{children}</> return <>{children}</>
} }
+1 -1
View File
@@ -30,7 +30,7 @@ export function LoginPage() {
onError: (e) => toast({ title: "登录失败", description: e.message }), onError: (e) => toast({ title: "登录失败", description: e.message }),
}) })
const turnstileRequired = !!publicSettings.data?.turnstileEnabled const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/mail" replace /> if (me.data?.user) return <Navigate to="/" replace />
return ( return (
<div className="grid min-h-screen place-items-center bg-background px-4"> <div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-[360px]"> <div className="w-full max-w-[360px]">
+401 -41
View File
@@ -1,19 +1,21 @@
import * as React from "react" import * as React from "react"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { marked } from "marked"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import { Archive, Check, ChevronsUpDown, Copy, Forward, Inbox, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Sun, Trash2 } from "lucide-react" import { Archive, Bold, Check, ChevronsUpDown, Code2, Copy, Forward, Image, Inbox, Italic, Link, List, ListOrdered, Mail, MailCheck, Minus, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, RefreshCcw, Reply, Search, Send, Settings, SlidersHorizontal, Star, Strikethrough, Sun, Tag, Trash2, WrapText, X } from "lucide-react"
import { api, Mailbox, MailMessage } from "@/lib/api" import { api, Mailbox, MailFolder, MailLabel, MailMessage } from "@/lib/api"
import { cn, formatBytes, formatDate } from "@/lib/utils" import { cn, formatBytes, formatDate } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@@ -46,6 +48,10 @@ const folderLabels: Record<string, string> = {
type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string } type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string }
type MailFilter = "all" | "unread" | "starred" | "attachments" type MailFilter = "all" | "unread" | "starred" | "attachments"
type MailView = "folder" | "starred" | "label"
type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
const filterLabels: Record<MailFilter, string> = { const filterLabels: Record<MailFilter, string> = {
all: "全部邮件", all: "全部邮件",
@@ -59,8 +65,9 @@ export function MailPage() {
const { toast } = useToast() const { toast } = useToast()
const navigate = useNavigate() const navigate = useNavigate()
const me = useMe() const me = useMe()
const [searchParams, setSearchParams] = useSearchParams() const [folder, setFolder] = React.useState("Inbox")
const [folder, setFolder] = React.useState(() => searchParams.get("folder") || "Inbox") const [mailView, setMailView] = React.useState<MailView>("folder")
const [selectedLabelId, setSelectedLabelId] = React.useState("")
const [query, setQuery] = React.useState("") const [query, setQuery] = React.useState("")
const [selectedId, setSelectedId] = React.useState<string | null>(null) const [selectedId, setSelectedId] = React.useState<string | null>(null)
const [composeOpen, setComposeOpen] = React.useState(false) const [composeOpen, setComposeOpen] = React.useState(false)
@@ -76,11 +83,47 @@ export function MailPage() {
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId }) const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId })
const messages = useQuery({ queryKey: ["messages", selectedMailboxId, folder, query], queryFn: () => api.messages(folder, query, "", selectedMailboxId), enabled: !!selectedMailboxId }) const labels = useQuery({ queryKey: ["labels", selectedMailboxId], queryFn: () => api.labels(selectedMailboxId), enabled: !!selectedMailboxId })
const mailStats = useQuery({ queryKey: ["mail-stats", selectedMailboxId], queryFn: () => api.mailStats(selectedMailboxId), enabled: !!selectedMailboxId })
const messages = useQuery({
queryKey: ["messages", selectedMailboxId, mailView, folder, selectedLabelId, query],
queryFn: () => {
if (mailView === "starred") return api.starredMessages(query, "", selectedMailboxId)
if (mailView === "label") return api.labelMessages(selectedLabelId, query, "", selectedMailboxId)
return api.messages(folder, query, "", selectedMailboxId)
},
enabled: !!selectedMailboxId && (mailView !== "label" || !!selectedLabelId),
})
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!), enabled: !!selectedId }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!), enabled: !!selectedId })
const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }) }) const star = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => api.star(id, starred), onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }) } })
const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); toast({ title: "已删除" }) } }) const addLabel = useMutation({
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"] }); toast({ title: "已移动" }) } }) 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"] })
},
onError: (error) => toast({ title: "添加标签失败", description: error.message }),
})
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"] })
},
onError: (error) => toast({ title: "移除标签失败", description: error.message }),
})
const createLabel = useMutation({
mutationFn: (name: string) => api.createLabel({ mailboxId: selectedMailboxId, name }),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["labels"] })
toast({ title: "标签已创建" })
},
onError: (error) => toast({ title: "创建标签失败", description: error.message }),
})
const del = useMutation({ mutationFn: (id: string) => api.delete(id), 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: "已删除" }) } })
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: "已移动" }) } })
const markAllRead = useMutation({ const markAllRead = useMutation({
mutationFn: async (items: MailMessage[]) => { mutationFn: async (items: MailMessage[]) => {
const unread = items.filter((message) => !message.isRead) const unread = items.filter((message) => !message.isRead)
@@ -90,6 +133,8 @@ export function MailPage() {
onSuccess: async (count) => { onSuccess: async (count) => {
await qc.invalidateQueries({ queryKey: ["messages"] }) await qc.invalidateQueries({ queryKey: ["messages"] })
await qc.invalidateQueries({ queryKey: ["folders"] }) await qc.invalidateQueries({ queryKey: ["folders"] })
await qc.invalidateQueries({ queryKey: ["mail-stats"] })
await qc.invalidateQueries({ queryKey: ["labels"] })
toast({ title: count > 0 ? `已标记 ${count} 封邮件为已读` : "当前没有未读邮件" }) toast({ title: count > 0 ? `已标记 ${count} 封邮件为已读` : "当前没有未读邮件" })
}, },
onError: (error) => toast({ title: "操作失败", description: error.message }), onError: (error) => toast({ title: "操作失败", description: error.message }),
@@ -108,12 +153,9 @@ export function MailPage() {
}, [selectedMailboxId]) }, [selectedMailboxId])
React.useEffect(() => { React.useEffect(() => {
const nextFolder = searchParams.get("folder") || "Inbox" setSelectedId(null)
if (nextFolder !== folder) { setMailFilter("all")
setFolder(nextFolder) }, [mailView])
setSelectedId(null)
}
}, [folder, searchParams])
React.useEffect(() => { React.useEffect(() => {
applyTheme(darkMode, themeMountedRef.current) applyTheme(darkMode, themeMountedRef.current)
@@ -122,7 +164,11 @@ export function MailPage() {
React.useEffect(() => { React.useEffect(() => {
const events = new EventSource("/api/events", { withCredentials: true }) const events = new EventSource("/api/events", { withCredentials: true })
events.addEventListener("sync", () => qc.invalidateQueries({ queryKey: ["folders"] })) events.addEventListener("sync", () => {
qc.invalidateQueries({ queryKey: ["folders"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] })
})
return () => events.close() return () => events.close()
}, [qc]) }, [qc])
@@ -132,6 +178,8 @@ export function MailPage() {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
qc.invalidateQueries({ queryKey: ["messages"] }) qc.invalidateQueries({ queryKey: ["messages"] })
qc.invalidateQueries({ queryKey: ["folders"] }) qc.invalidateQueries({ queryKey: ["folders"] })
qc.invalidateQueries({ queryKey: ["mail-stats"] })
qc.invalidateQueries({ queryKey: ["labels"] })
}, interval) }, interval)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc]) }, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc])
@@ -145,13 +193,38 @@ export function MailPage() {
return true return true
}) })
const unreadCount = allMessages.filter((message) => !message.isRead).length const unreadCount = allMessages.filter((message) => !message.isRead).length
const starredCount = mailStats.data?.starredMessages ?? (mailView === "starred" ? allMessages.length : 0)
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount)
const labelItems = labels.data?.items || []
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
const viewTitle = mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
const emptyMessage = allMessages.length === 0 ? (mailView === "starred" ? "暂无星标邮件" : mailView === "label" ? "当前标签没有邮件" : "当前文件夹没有邮件") : "当前筛选条件下没有邮件"
function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) } function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) }
function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) }
function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) }
function switchMailbox(mailboxId: string) { function switchMailbox(mailboxId: string) {
setSelectedMailboxId(mailboxId) setSelectedMailboxId(mailboxId)
setFolder("Inbox") setFolder("Inbox")
setSearchParams({}) setMailView("folder")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
}
function openFolder(nextFolder: string) {
setFolder(nextFolder)
setMailView("folder")
setSelectedLabelId("")
setSelectedId(null)
}
function openStarred() {
setMailView("starred")
setSelectedLabelId("")
setSelectedId(null)
setMailFilter("all")
}
function openLabel(labelId: string) {
setSelectedLabelId(labelId)
setMailView("label")
setSelectedId(null) setSelectedId(null)
setMailFilter("all") setMailFilter("all")
} }
@@ -211,12 +284,16 @@ export function MailPage() {
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>} {!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{(folders.data?.items || []).map((f) => ( {mailMenuItems.map((item) => (
<SidebarMenuItem key={f.id}> <SidebarMenuItem key={item.key}>
<SidebarMenuButton isActive={folder === f.name} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => { setFolder(f.name); setSearchParams(f.name === "Inbox" ? {} : { folder: f.name }); setSelectedId(null) }}> <SidebarMenuButton
{folderIcons[f.role] || <Inbox className="h-4 w-4" />} isActive={item.type === "starred" ? mailView === "starred" : mailView === "folder" && folder === item.folderName}
{!sidebarCollapsed && <span>{folderLabels[f.name] || f.name}</span>} className={cn(sidebarCollapsed && "justify-center px-0")}
{!sidebarCollapsed && f.unreadCount > 0 && <Badge variant="secondary" className="ml-auto">{f.unreadCount}</Badge>} onClick={() => item.type === "starred" ? openStarred() : openFolder(item.folderName)}
>
{item.icon}
{!sidebarCollapsed && <span>{item.label}</span>}
{!sidebarCollapsed && item.count > 0 && <Badge variant="secondary" className="ml-auto">{item.count}</Badge>}
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
@@ -224,6 +301,27 @@ export function MailPage() {
{folders.isLoading && <FolderSkeleton />} {folders.isLoading && <FolderSkeleton />}
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{labelItems.map((label) => (
<SidebarMenuItem key={label.id}>
<SidebarMenuButton isActive={mailView === "label" && selectedLabelId === label.id} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => openLabel(label.id)}>
<Tag className="h-4 w-4" style={{ color: label.color }} />
{!sidebarCollapsed && <span>{label.name}</span>}
{!sidebarCollapsed && !!label.messageCount && <Badge variant="secondary" className="ml-auto">{label.messageCount}</Badge>}
</SidebarMenuButton>
</SidebarMenuItem>
))}
{!sidebarCollapsed && !labels.isLoading && labelItems.length === 0 && <div className="px-2 py-1 text-xs text-muted-foreground"></div>}
<SidebarMenuItem>
<NewLabelButton collapsed={sidebarCollapsed} pending={createLabel.isPending} onCreate={(name) => createLabel.mutate(name)} />
</SidebarMenuItem>
</SidebarMenu>
{labels.isLoading && <FolderSkeleton />}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}> <div className={cn("mt-auto border-t p-2", sidebarCollapsed ? "flex justify-center" : "")}>
<Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}> <Button type="button" variant="ghost" size={sidebarCollapsed ? "icon" : "sm"} className={cn(!sidebarCollapsed && "w-full justify-start")} onClick={toggleSidebar}>
@@ -239,7 +337,7 @@ export function MailPage() {
<section className="flex h-full min-h-0 flex-col"> <section className="flex h-full min-h-0 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5"> <header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }) }}><RefreshCcw className="h-4 w-4" /></Button> <Button size="icon" variant="ghost" onClick={() => { qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }}><RefreshCcw className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button> <Button variant="outline" size="sm" disabled={markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" /></Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -265,14 +363,14 @@ export function MailPage() {
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-5"> <div className="flex h-14 shrink-0 items-center justify-between border-b px-5">
<div> <div>
<div className="text-sm font-semibold">{folderLabels[folder] || folder}</div> <div className="flex items-center gap-2 text-sm font-semibold">{mailView === "label" && selectedLabel && <Tag className="h-4 w-4" style={{ color: selectedLabel.color }} />}{viewTitle}</div>
<div className="text-xs text-muted-foreground">{visibleMessages.length} / {allMessages.length} </div> <div className="text-xs text-muted-foreground">{visibleMessages.length} / {allMessages.length} </div>
</div> </div>
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
{messages.isLoading && <MessageSkeleton />} {messages.isLoading && <MessageSkeleton />}
{visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} onClick={() => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {visibleMessages.map((m) => <MessageRow key={m.id} message={m} active={selectedId === m.id} onClick={() => setSelectedId(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)}
{!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{allMessages.length === 0 ? "当前文件夹没有邮件" : "当前筛选条件下没有邮件"}</div>} {!messages.isLoading && visibleMessages.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{emptyMessage}</div>}
</ScrollArea> </ScrollArea>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -298,6 +396,13 @@ export function MailPage() {
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDate(selected.receivedAt)}</div> <div className="text-sm text-muted-foreground"><span className="font-medium text-foreground">{selected.from}</span> {selected.to.join(", ")} · {formatDate(selected.receivedAt)}</div>
<MessageLabels
messageLabels={selected.labels || []}
availableLabels={labelItems}
onAdd={(label) => addLabel.mutate({ id: selected.id, label })}
onRemove={(labelId) => removeLabel.mutate({ id: selected.id, labelId })}
pending={addLabel.isPending || removeLabel.isPending}
/>
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-6"> <div className="p-6">
@@ -314,14 +419,64 @@ export function MailPage() {
</ResizablePanelGroup> </ResizablePanelGroup>
</SidebarProvider> </SidebarProvider>
<ComposeSheet mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }) }} /> <ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }} />
</div> </div>
) )
} }
function buildMailMenuItems(folders: MailFolder[], starredCount: number): MailMenuItem[] {
const folderItems: MailMenuItem[] = folders.map((item) => ({
type: "folder",
key: item.id,
folderName: item.name,
label: folderLabels[item.name] || item.name,
icon: folderIcons[item.role] || <Inbox className="h-4 w-4" />,
count: item.unreadCount,
}))
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
}
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> } function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> } function MessageSkeleton() { return <div className="space-y-0">{Array.from({ length: 6 }).map((_, i) => <div className="space-y-2 border-b p-4" key={i}><Skeleton className="h-4 w-1/2" /><Skeleton className="h-4 w-4/5" /><Skeleton className="h-3 w-full" /></div>)}</div> }
function NewLabelButton({ collapsed, pending, onCreate }: { collapsed: boolean; pending: boolean; onCreate: (name: string) => void }) {
const [editing, setEditing] = React.useState(false)
const [value, setValue] = React.useState("")
if (collapsed) {
return (
<SidebarMenuButton className="justify-center px-0" onClick={() => setEditing(true)}>
<Plus className="h-4 w-4" />
</SidebarMenuButton>
)
}
if (editing) {
return (
<form
className="px-2 py-1"
onSubmit={(event) => {
event.preventDefault()
const name = value.trim()
if (!name) return
onCreate(name)
setValue("")
setEditing(false)
}}
>
<Input autoFocus value={value} onChange={(event) => setValue(event.target.value)} onBlur={() => { if (!value.trim()) setEditing(false) }} placeholder="新建标签" disabled={pending} />
</form>
)
}
return (
<SidebarMenuButton className="text-muted-foreground" onClick={() => setEditing(true)}>
<Plus className="h-4 w-4" />
<span></span>
</SidebarMenuButton>
)
}
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) { function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) {
const displayName = cleanAccountName(name, email) const displayName = cleanAccountName(name, email)
if (collapsed) { if (collapsed) {
@@ -398,11 +553,55 @@ function MessageRow({ message, active, onClick, onStar }: { message: MailMessage
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} className={cn("cursor-pointer border-b p-4 transition-colors hover:bg-accent/50", active && "bg-accent", !message.isRead && "font-semibold")}>
<div className="mb-1 flex items-center justify-between gap-2"><div className="truncate text-sm">{message.from}</div><div className="shrink-0 text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div></div> <div className="mb-1 flex items-center justify-between gap-2"><div className="truncate text-sm">{message.from}</div><div className="shrink-0 text-xs text-muted-foreground">{formatDate(message.receivedAt)}</div></div>
<div className="mb-1 flex items-center gap-2"><Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-yellow-500" onClick={(e) => { e.stopPropagation(); onStar() }}><Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /></Button><span className="truncate text-sm">{message.subject}</span>{message.hasAttachments && <Paperclip className="h-3 w-3 text-muted-foreground" />}</div> <div className="mb-1 flex items-center gap-2"><Button type="button" variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-yellow-500" onClick={(e) => { e.stopPropagation(); onStar() }}><Star className={cn("h-4 w-4", message.isStarred && "fill-yellow-400 text-yellow-500")} /></Button><span className="truncate text-sm">{message.subject}</span>{message.hasAttachments && <Paperclip className="h-3 w-3 text-muted-foreground" />}</div>
{message.labels && message.labels.length > 0 && <div className="mb-1 flex flex-wrap gap-1">{message.labels.map((label) => <span key={label.id} className="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: label.color, color: label.color }}>{label.name}</span>)}</div>}
<div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div> <div className="line-clamp-2 text-xs text-muted-foreground">{message.snippet}</div>
</div> </div>
} }
function ComposeSheet({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) { 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 (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Tag className="h-3.5 w-3.5" /></div>
<div className="flex flex-wrap items-center gap-2">
{messageLabels.map((label) => (
<span key={label.id} className="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium" style={{ borderColor: label.color, color: label.color }}>
{label.name}
<Button type="button" variant="ghost" size="icon" className="h-4 w-4 rounded-full p-0 hover:bg-black/5" onClick={() => onRemove(label.id)} disabled={pending}>
<X className="h-3 w-3" />
</Button>
</span>
))}
{messageLabels.length === 0 && <span className="text-xs text-muted-foreground"></span>}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={pending}>
<Tag className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
{availableLabels.length === 0 && <DropdownMenuItem disabled></DropdownMenuItem>}
{availableLabels.map((label) => (
<DropdownMenuCheckboxItem
key={label.id}
checked={activeIds.has(label.id)}
onSelect={(event) => {
event.preventDefault()
activeIds.has(label.id) ? onRemove(label.id) : onAdd(label)
}}
>
<span className="mr-2 h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
<span>{label.name}</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) {
const { toast } = useToast() const { toast } = useToast()
const [files, setFiles] = React.useState<File[]>([]) const [files, setFiles] = React.useState<File[]>([])
const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) }) const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) })
@@ -415,20 +614,181 @@ function ComposeSheet({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?
const form = new FormData(e.currentTarget) const form = new FormData(e.currentTarget)
const attachments = await Promise.all(files.map(fileToAttachment)) const attachments = await Promise.all(files.map(fileToAttachment))
const text = String(form.get("text") || "") const text = String(form.get("text") || "")
send.mutate({ mailboxId: mailbox.id, to: splitEmails(String(form.get("to") || "")), cc: splitEmails(String(form.get("cc") || "")), bcc: splitEmails(String(form.get("bcc") || "")), subject: String(form.get("subject") || ""), text, html: text.replace(/\n/g, "<br>"), attachments }) send.mutate({ mailboxId: mailbox.id, to: splitEmails(String(form.get("to") || "")), cc: splitEmails(String(form.get("cc") || "")), bcc: splitEmails(String(form.get("bcc") || "")), subject: String(form.get("subject") || ""), text, html: markdownToHtml(text), attachments })
} }
return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent className="overflow-y-auto sm:max-w-2xl"><SheetHeader><SheetTitle></SheetTitle></SheetHeader><form key={draft?.key || "new"} className="mt-5 space-y-4" onSubmit={submit}> return (
<div className="space-y-2"><Label></Label><Input value={mailbox?.address || "未选择"} readOnly /></div> <Dialog open={open} onOpenChange={onOpenChange}>
<div className="space-y-2"><Label></Label><Input name="to" placeholder="user@example.com, other@example.com" defaultValue={draft?.to || ""} required /></div> <DialogContent className="w-[min(92vw,64rem)] max-w-none overflow-hidden p-0">
<div className="grid grid-cols-2 gap-3"><div className="space-y-2"><Label></Label><Input name="cc" defaultValue={draft?.cc || ""} /></div><div className="space-y-2"><Label></Label><Input name="bcc" defaultValue={draft?.bcc || ""} /></div></div> <form key={draft?.key || "new"} className="flex max-h-[90vh] flex-col" onSubmit={submit}>
<div className="space-y-2"><Label></Label><Input name="subject" defaultValue={draft?.subject || ""} /></div> <DialogHeader className="border-b px-6 py-5 text-left">
<div className="space-y-2"><Label></Label><Textarea name="text" className="min-h-[220px]" defaultValue={draft?.text || ""} /></div> <DialogTitle></DialogTitle>
<div className="space-y-2"><Label></Label><Input type="file" multiple onChange={(e) => setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 && <div className="text-xs text-muted-foreground">{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join("")}</div>}</div> </DialogHeader>
<SheetFooter><Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button><Button disabled={send.isPending || !mailbox}>{send.isPending ? "发送中..." : "发送"}</Button></SheetFooter> <div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
</form></SheetContent></Sheet> <div className="space-y-2"><Label></Label><Input value={mailbox?.address || "未选择"} readOnly /></div>
<div className="space-y-2"><Label></Label><Input name="to" placeholder="user@example.com, other@example.com" defaultValue={draft?.to || ""} required /></div>
<div className="grid grid-cols-2 gap-3"><div className="space-y-2"><Label></Label><Input name="cc" placeholder="cc1@example.com, cc2@example.com" defaultValue={draft?.cc || ""} /></div><div className="space-y-2"><Label></Label><Input name="bcc" placeholder="bcc1@example.com, bcc2@example.com" defaultValue={draft?.bcc || ""} /></div></div>
<div className="space-y-2"><Label></Label><Input name="subject" defaultValue={draft?.subject || ""} /></div>
<MarkdownComposer defaultValue={draft?.text || ""} />
<div className="space-y-2"><Label></Label><Input type="file" multiple onChange={(e) => setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 && <div className="text-xs text-muted-foreground">{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join("")}</div>}</div>
</div>
<DialogFooter className="border-t bg-background px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button disabled={send.isPending || !mailbox}>{send.isPending ? "发送中..." : "发送"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
} }
function splitEmails(s: string) { return s.split(/[;,\s]+/).map((v) => v.trim()).filter(Boolean) } type MarkdownAction = "bold" | "italic" | "strike" | "ul" | "ol" | "quote" | "code" | "link" | "image" | "hr"
type MarkdownMode = "edit" | "split" | "preview"
function MarkdownComposer({ defaultValue }: { defaultValue: string }) {
const [value, setValue] = React.useState(defaultValue)
const [mode, setMode] = React.useState<MarkdownMode>("edit")
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const previewHtml = React.useMemo(() => markdownToHtml(value), [value])
React.useEffect(() => setValue(defaultValue), [defaultValue])
function focusEditor() {
window.requestAnimationFrame(() => textareaRef.current?.focus())
}
function updateSelection(next: string, start: number, end: number) {
setValue(next)
window.requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(start, end)
})
}
function wrap(prefix: string, suffix = prefix, placeholder = "文本") {
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = value.slice(start, end) || placeholder
const next = value.slice(0, start) + prefix + selected + suffix + value.slice(end)
updateSelection(next, start + prefix.length, start + prefix.length + selected.length)
}
function prefixLines(prefix: string, ordered = false) {
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const lineStart = value.lastIndexOf("\n", start - 1) + 1
const lineEndIndex = value.indexOf("\n", end)
const lineEnd = lineEndIndex === -1 ? value.length : lineEndIndex
const block = value.slice(lineStart, lineEnd) || "列表项"
const lines = block.split("\n")
const formatted = lines.map((line, index) => `${ordered ? `${index + 1}. ` : prefix}${line || "列表项"}`).join("\n")
updateSelection(value.slice(0, lineStart) + formatted + value.slice(lineEnd), lineStart, lineStart + formatted.length)
}
function insertMarkdown(action: MarkdownAction) {
if (mode === "preview") setMode("edit")
const textarea = textareaRef.current
if (!textarea) {
focusEditor()
return
}
switch (action) {
case "bold": wrap("**", "**", "加粗文本"); break
case "italic": wrap("_", "_", "斜体文本"); break
case "strike": wrap("~~", "~~", "删除线文本"); break
case "ul": prefixLines("- "); break
case "ol": prefixLines("", true); break
case "quote": prefixLines("> "); break
case "code": wrap("`", "`", "code"); break
case "link": wrap("[", "](https://example.com)", "链接文本"); break
case "image": wrap("![", "](https://example.com/image.png)", "图片描述"); break
case "hr": {
const start = textarea.selectionStart
const before = value.slice(0, start)
const after = value.slice(textarea.selectionEnd)
const prefix = before.endsWith("\n") || before === "" ? "" : "\n"
const suffix = after.startsWith("\n") || after === "" ? "" : "\n"
const insert = `${prefix}---${suffix}`
updateSelection(before + insert + after, before.length + insert.length, before.length + insert.length)
break
}
}
}
function setBlock(type: string) {
if (type === "p") return focusEditor()
const mark = type === "h2" ? "## " : "### "
prefixLines(mark)
}
return (
<div className="space-y-2">
<Label></Label>
<Input type="hidden" name="text" value={value} readOnly className="hidden" />
<div className="overflow-hidden rounded-md border border-input bg-background focus-within:ring-1 focus-within:ring-ring">
<div className="flex min-h-12 flex-wrap items-center gap-1 border-b bg-muted/30 px-3 py-2">
<ToolbarButton label="加粗" onClick={() => insertMarkdown("bold")}><Bold className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="斜体" onClick={() => insertMarkdown("italic")}><Italic className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="下划线" disabled><span className="text-base leading-none underline">U</span></ToolbarButton>
<ToolbarButton label="删除线" onClick={() => insertMarkdown("strike")}><Strikethrough className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" />
<Select defaultValue="p" onValueChange={setBlock}>
<SelectTrigger className="h-8 w-[96px] border-0 bg-transparent px-2 shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="p"></SelectItem>
<SelectItem value="h2"> 2</SelectItem>
<SelectItem value="h3"> 3</SelectItem>
</SelectContent>
</Select>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarButton label="无序列表" onClick={() => insertMarkdown("ul")}><List className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="有序列表" onClick={() => insertMarkdown("ol")}><ListOrdered className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="引用" onClick={() => insertMarkdown("quote")}><Quote className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="代码" onClick={() => insertMarkdown("code")}><Code2 className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarButton label="链接" onClick={() => insertMarkdown("link")}><Link className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="图片" onClick={() => insertMarkdown("image")}><Image className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="分隔线" onClick={() => insertMarkdown("hr")}><Minus className="h-4 w-4" /></ToolbarButton>
<div className="ml-auto flex items-center gap-2">
<Badge variant="secondary" className="h-8 gap-1.5 rounded-md bg-foreground px-2 text-xs text-background hover:bg-foreground">
<WrapText className="h-4 w-4" /> Markdown
</Badge>
<div className="flex rounded-md border bg-background p-0.5">
{(["edit", "split", "preview"] as MarkdownMode[]).map((item) => (
<Button key={item} type="button" variant={mode === item ? "secondary" : "ghost"} size="sm" className="h-7 rounded px-2 text-xs" onClick={() => setMode(item)}>
{item === "edit" ? "编辑" : item === "split" ? "分屏" : "预览"}
</Button>
))}
</div>
</div>
</div>
<div className={cn(mode === "split" && "grid md:grid-cols-2")}>
{mode !== "preview" && (
<Textarea
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="在此输入邮件内容..."
className={cn("min-h-[280px] resize-y rounded-none border-0 shadow-none focus-visible:ring-0", mode === "split" && "md:border-r")}
/>
)}
{mode !== "edit" && (
<div className="mail-html min-h-[280px] overflow-y-auto p-4 text-sm leading-7" dangerouslySetInnerHTML={{ __html: previewHtml || "<p></p>" }} />
)}
</div>
</div>
</div>
)
}
function ToolbarButton({ label, children, onClick, disabled }: { label: string; children: React.ReactNode; onClick?: () => void; disabled?: boolean }) {
return <Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground" title={label} aria-label={label} onClick={onClick} disabled={disabled}>{children}</Button>
}
function splitEmails(s: string) { return s.split(/[;,\s]+/).map((v) => v.trim()).filter(Boolean) }
function markdownToHtml(value: string) { return DOMPurify.sanitize(marked.parse(value, { async: false, breaks: true })) }
function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` } function withPrefix(subject: string, prefix: string) { return subject.toLowerCase().startsWith(prefix.toLowerCase()) ? subject : `${prefix} ${subject}` }
function quoteMessage(message: MailMessage) { function quoteMessage(message: MailMessage) {
const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "") const body = message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")
+4 -3
View File
@@ -125,7 +125,8 @@ export function ProfilePage() {
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) } async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) } function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }) }
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) } function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
if (!user) return <div className="grid h-svh place-items-center text-muted-foreground">...</div> if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">...</div>
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div>
return ( return (
<div className="h-svh bg-background"> <div className="h-svh bg-background">
@@ -134,7 +135,7 @@ export function ProfilePage() {
<ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}> <ResizablePanel ref={sidebarPanelRef} collapsible collapsedSize={4} defaultSize={15} minSize={11} maxSize={24} onCollapse={() => setSidebarCollapsed(true)} onExpand={() => setSidebarCollapsed(false)}>
<Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar"> <Sidebar collapsible="none" className="h-full w-full border-r bg-sidebar">
<SidebarHeader className={cn("border-b py-4", sidebarCollapsed ? "px-2" : "px-4")}> <SidebarHeader className={cn("border-b py-4", sidebarCollapsed ? "px-2" : "px-4")}>
<AccountHeader collapsed={sidebarCollapsed} name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/mail")} /> <AccountHeader collapsed={sidebarCollapsed} name={user.displayName || selectedMailbox?.address || "LanQin"} email={user.email || selectedMailbox?.address} darkMode={darkMode} onToggleTheme={() => setDarkMode((v) => !v)} onBack={() => navigate("/")} />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
@@ -169,7 +170,7 @@ export function ProfilePage() {
) )
function renderTab() { function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/mail") }} /> if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} /> if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />