feat(mail): 支持邮件标签与富文本写信
- 新增标签数据结构、接口与路由,支持创建/删除邮件标签、按标签筛选邮件和查看星标邮件。 - 邮件详情与列表补充标签展示,发送/移动/删除/已读等操作同步刷新相关数据。 - 前端重构邮件页,加入标签侧栏、标签管理弹层,并将写信编辑器升级为 Markdown 富文本。 - 顺带优化登录态超时处理与部分导航入口,调整前端依赖版本。
This commit is contained in:
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -102,6 +103,14 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
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")
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
@@ -111,6 +120,19 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
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"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
@@ -118,16 +140,14 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit := 30
|
||||
|
||||
args := []any{mb.ID, folderID}
|
||||
where := `mailbox_id=? AND folder_id=?`
|
||||
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 + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
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
|
||||
FROM messages WHERE ` + where + ` ORDER BY received_at DESC LIMIT ? OFFSET ?`
|
||||
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 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...)
|
||||
if err != nil {
|
||||
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()
|
||||
items := []MailMessage{}
|
||||
for rows.Next() {
|
||||
msg, err := scanMessageSummary(rows, folder)
|
||||
msg, err := scanMessageSummary(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan messages")
|
||||
return
|
||||
@@ -148,9 +168,104 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
items = items[: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})
|
||||
}
|
||||
|
||||
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) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
|
||||
if err != nil {
|
||||
@@ -509,6 +624,11 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma
|
||||
}
|
||||
msg.Attachments = atts
|
||||
}
|
||||
labels, err := a.labelsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Labels = labels
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
@@ -588,6 +708,138 @@ func (a *App) attachmentsForMessage(ctx context.Context, messageID string) ([]At
|
||||
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) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT storage_path FROM attachments WHERE message_id=?`, messageID)
|
||||
if err != nil {
|
||||
@@ -619,15 +871,14 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) {
|
||||
func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
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 {
|
||||
return msg, err
|
||||
}
|
||||
msg.Folder = folder
|
||||
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
||||
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
||||
|
||||
Reference in New Issue
Block a user