feat(mail): 支持邮件标签与富文本写信
- 新增标签数据结构、接口与路由,支持创建/删除邮件标签、按标签筛选邮件和查看星标邮件。 - 邮件详情与列表补充标签展示,发送/移动/删除/已读等操作同步刷新相关数据。 - 前端重构邮件页,加入标签侧栏、标签管理弹层,并将写信编辑器升级为 Markdown 富文本。 - 顺带优化登录态超时处理与部分导航入口,调整前端依赖版本。
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+597
-1178
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -65,7 +66,7 @@ export function ProtectedLayout() {
|
|||||||
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -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()
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
const externalSignal = init.signal
|
||||||
|
if (externalSignal) {
|
||||||
|
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) {
|
if (!res.ok) {
|
||||||
let message = `${res.status} ${res.statusText}`
|
let message = `${res.status} ${res.statusText}`
|
||||||
try { const body = await res.json(); message = body.error || message } catch {}
|
try { const body = await res.json(); message = body.error || message } catch {}
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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" }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
+396
-36
@@ -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"
|
|
||||||
if (nextFolder !== folder) {
|
|
||||||
setFolder(nextFolder)
|
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
}
|
setMailFilter("all")
|
||||||
}, [folder, searchParams])
|
}, [mailView])
|
||||||
|
|
||||||
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(92vw,64rem)] max-w-none overflow-hidden p-0">
|
||||||
|
<form key={draft?.key || "new"} className="flex max-h-[90vh] flex-col" onSubmit={submit}>
|
||||||
|
<DialogHeader className="border-b px-6 py-5 text-left">
|
||||||
|
<DialogTitle>写信</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-2"><Label>发件邮箱</Label><Input value={mailbox?.address || "未选择"} readOnly /></div>
|
<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="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" defaultValue={draft?.cc || ""} /></div><div className="space-y-2"><Label>密送</Label><Input name="bcc" defaultValue={draft?.bcc || ""} /></div></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>
|
<div className="space-y-2"><Label>主题</Label><Input name="subject" defaultValue={draft?.subject || ""} /></div>
|
||||||
<div className="space-y-2"><Label>正文</Label><Textarea name="text" className="min-h-[220px]" defaultValue={draft?.text || ""} /></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 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>
|
||||||
<SheetFooter><Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={send.isPending || !mailbox}>{send.isPending ? "发送中..." : "发送"}</Button></SheetFooter>
|
</div>
|
||||||
</form></SheetContent></Sheet>
|
<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("", "图片描述"); 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 || "")
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user