package app

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"image/png"
	"io"
	"net/http"
	"net/textproto"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
)

// mailMessagesPageSize is the max number of messages returned per page in mail listing.
const mailMessagesPageSize = 30

const customFolderDefaultSortOrderBase = 100000

func isAllMailboxID(mailboxID string) bool {
	return strings.EqualFold(strings.TrimSpace(mailboxID), "all")
}

type AttachmentInput struct {
	Filename      string `json:"filename"`
	ContentType   string `json:"contentType"`
	ContentBase64 string `json:"contentBase64"`
}

type storedMessage struct {
	MailboxID      string
	FolderID       string
	RecipientAddr  string
	MessageUID     string
	MessageID      string
	Subject        string
	From           string
	FromName       string
	To             []string
	CC             []string
	BCC            []string
	SentAt         time.Time
	ReceivedAt     time.Time
	Snippet        string
	BodyText       string
	BodyHTML       string
	IsRead         bool
	IsStarred      bool
	RawPath        string
	Authentication MailAuthentication
}

func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at,
		COALESCE(SUM(CASE WHEN lower(f.name)='inbox' AND m.is_read=0 THEN 1 ELSE 0 END),0) AS unread_count
		FROM mailboxes mb
		JOIN domains d ON d.id=mb.domain_id
		LEFT JOIN folders f ON f.mailbox_id=mb.id
		LEFT JOIN messages m ON m.folder_id=f.id
		WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
		GROUP BY mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
		ORDER BY mb.address`, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
		return
	}
	defer rows.Close()
	items := []Mailbox{}
	for rows.Next() {
		var m Mailbox
		var created string
		if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created, &m.UnreadCount); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
			return
		}
		m.UserEmail = user.Email
		m.CreatedAt = parseTime(created)
		items = append(items, m)
	}
	markPrimaryMailboxes(items)
	respondJSON(w, http.StatusOK, map[string]any{"items": items})
}

func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		a.handleAllMailFolders(w, r)
		return
	}
	mb, err := a.mailboxForCurrentUser(r)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,f.icon,
		COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
		COUNT(m.id) AS total,
		f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
		FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
		WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
		ORDER BY CASE
			WHEN lower(f.name)='inbox' THEN 1000
			WHEN lower(f.name)='sent' THEN 5000
			WHEN lower(f.name)='drafts' THEN 6000
			WHEN lower(f.name)='archive' THEN 7000
			WHEN lower(f.name)='spam' THEN 8000
			WHEN lower(f.name)='trash' THEN 9000
			ELSE f.sort_order
		END, f.created_at,f.name`, mb.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folders")
		return
	}
	defer rows.Close()
	items := []MailFolder{}
	for rows.Next() {
		var f MailFolder
		if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan folders")
			return
		}
		items = append(items, f)
	}
	respondJSON(w, http.StatusOK, map[string]any{"items": items})
}

func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,MIN(f.icon),
		COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
		COUNT(m.id) AS total,
		MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
		FROM folders f
		JOIN mailboxes mb ON mb.id=f.mailbox_id
		LEFT JOIN messages m ON m.folder_id=f.id
		WHERE mb.user_id=? AND mb.status='active'
		GROUP BY f.name,f.role
		ORDER BY CASE
			WHEN lower(f.name)='inbox' THEN 1000
			WHEN lower(f.name)='sent' THEN 5000
			WHEN lower(f.name)='drafts' THEN 6000
			WHEN lower(f.name)='archive' THEN 7000
			WHEN lower(f.name)='spam' THEN 8000
			WHEN lower(f.name)='trash' THEN 9000
			ELSE MIN(f.sort_order)
		END, f.name`, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folders")
		return
	}
	defer rows.Close()
	items := []MailFolder{}
	for rows.Next() {
		var f MailFolder
		if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan folders")
			return
		}
		items = append(items, f)
	}
	respondJSON(w, http.StatusOK, map[string]any{"items": items})
}

func (a *App) handleReorderMailFolders(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 {
		FolderIDs []string `json:"folderIds"`
		Folders   []struct {
			ID        string `json:"id"`
			SortOrder int    `json:"sortOrder"`
		} `json:"folders"`
	}
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	seen := map[string]bool{}
	if len(req.Folders) == 0 {
		for i, id := range req.FolderIDs {
			id = strings.TrimSpace(id)
			req.Folders = append(req.Folders, struct {
				ID        string `json:"id"`
				SortOrder int    `json:"sortOrder"`
			}{ID: id, SortOrder: customFolderDefaultSortOrderBase + i + 1})
		}
	}
	for i := range req.Folders {
		req.Folders[i].ID = strings.TrimSpace(req.Folders[i].ID)
		if req.Folders[i].ID == "" || req.Folders[i].SortOrder <= 0 || seen[req.Folders[i].ID] {
			badRequest(w, errors.New("invalid folder order"))
			return
		}
		seen[req.Folders[i].ID] = true
	}
	if len(req.Folders) == 0 {
		badRequest(w, errors.New("folderIds is required"))
		return
	}
	rows, err := a.db.QueryContext(r.Context(), `SELECT id,name FROM folders WHERE mailbox_id=?`, mb.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folders")
		return
	}
	customIDs := map[string]bool{}
	for rows.Next() {
		var id, name string
		if err := rows.Scan(&id, &name); err != nil {
			rows.Close()
			respondError(w, http.StatusInternalServerError, "failed to scan folders")
			return
		}
		if !isSystemFolderName(name) {
			customIDs[id] = true
		}
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		respondError(w, http.StatusInternalServerError, "failed to scan folders")
		return
	}
	rows.Close()
	if len(customIDs) != len(req.Folders) {
		badRequest(w, errors.New("invalid folder order"))
		return
	}
	for _, item := range req.Folders {
		if !customIDs[item.ID] {
			badRequest(w, errors.New("invalid folder order"))
			return
		}
	}
	tx, err := a.db.BeginTx(r.Context(), nil)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to reorder folders")
		return
	}
	defer tx.Rollback()
	for _, item := range req.Folders {
		res, err := tx.ExecContext(r.Context(), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?`, item.SortOrder, item.ID, mb.ID)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to reorder folders")
			return
		}
		if affected, err := res.RowsAffected(); err != nil || affected != 1 {
			badRequest(w, errors.New("invalid folder order"))
			return
		}
	}
	if err := tx.Commit(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to reorder folders")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Name string `json:"name"`
		Icon string `json:"icon"`
	}
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	name, err := normalizeCustomFolderName(req.Name)
	if err != nil {
		badRequest(w, err)
		return
	}
	if isSystemFolderName(name) {
		badRequest(w, errors.New("system folder already exists"))
		return
	}
	icon := folderIconForName(name, req.Icon)
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		user := currentUser(r)
		rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id`, user.ID)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
			return
		}
		mailboxIDs := []string{}
		for rows.Next() {
			var mailboxID string
			if err := rows.Scan(&mailboxID); err != nil {
				rows.Close()
				respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
				return
			}
			mailboxIDs = append(mailboxIDs, mailboxID)
		}
		if err := rows.Err(); err != nil {
			rows.Close()
			respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
			return
		}
		rows.Close()
		if len(mailboxIDs) == 0 {
			respondError(w, http.StatusNotFound, "mailbox not found")
			return
		}
		for _, mailboxID := range mailboxIDs {
			if _, err := a.ensureCustomFolder(r.Context(), mailboxID, name, icon); err != nil {
				respondError(w, http.StatusInternalServerError, "failed to create folder")
				return
			}
		}
		respondJSON(w, http.StatusCreated, MailFolder{ID: "all-" + strings.ToLower(name), Name: name, Role: strings.ToLower(name), Icon: icon, SortOrder: customFolderDefaultSortOrderBase})
		return
	}
	mb, err := a.mailboxForCurrentUser(r)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name, icon)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to create folder")
		return
	}
	folder, err := a.folderByID(r.Context(), folderID, mb.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folder")
		return
	}
	respondJSON(w, http.StatusCreated, folder)
}

func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
	folderID := strings.TrimSpace(chi.URLParam(r, "id"))
	if folderID == "" {
		badRequest(w, errors.New("folder id is required"))
		return
	}
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		a.handleDeleteAllMailFolders(w, r, folderID)
		return
	}
	mb, err := a.mailboxForCurrentUser(r)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	var folderName string
	if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			respondError(w, http.StatusNotFound, "folder not found")
			return
		}
		respondError(w, http.StatusInternalServerError, "failed to load folder")
		return
	}
	if isSystemFolderName(folderName) {
		badRequest(w, errors.New("system folders cannot be deleted"))
		return
	}
	inboxID, err := a.ensureFolder(r.Context(), mb.ID, "Inbox")
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load inbox")
		return
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	tx, err := a.db.BeginTx(r.Context(), nil)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete folder")
		return
	}
	defer tx.Rollback()
	rows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, mb.ID, folderID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folder messages")
		return
	}
	var messageIDs []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
			return
		}
		messageIDs = append(messageIDs, id)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
		return
	}
	rows.Close()
	for _, messageID := range messageIDs {
		meta, err := a.nextIMAPMetadata(r.Context(), tx, inboxID)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
			return
		}
		if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to move folder messages")
			return
		}
	}
	if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete folder")
		return
	}
	if err := tx.Commit(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete folder")
		return
	}
	_, _ = a.bumpFolderModSeq(r.Context(), inboxID)
	respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
}

func (a *App) handleDeleteAllMailFolders(w http.ResponseWriter, r *http.Request, folderID string) {
	folderName := strings.TrimSpace(r.URL.Query().Get("folderName"))
	if folderName == "" && strings.HasPrefix(strings.ToLower(folderID), "all-") {
		folderName = strings.TrimSpace(folderID[4:])
	}
	name, err := normalizeCustomFolderName(folderName)
	if err != nil {
		badRequest(w, err)
		return
	}
	if isSystemFolderName(name) {
		badRequest(w, errors.New("system folders cannot be deleted"))
		return
	}
	user := currentUser(r)
	type folderTarget struct {
		folderID  string
		mailboxID string
		inboxID   string
	}
	rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.mailbox_id FROM folders f JOIN mailboxes mb ON mb.id=f.mailbox_id WHERE mb.user_id=? AND mb.status='active' AND lower(f.name)=lower(?)`, user.ID, name)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folders")
		return
	}
	targets := []folderTarget{}
	for rows.Next() {
		var target folderTarget
		if err := rows.Scan(&target.folderID, &target.mailboxID); err != nil {
			rows.Close()
			respondError(w, http.StatusInternalServerError, "failed to scan folders")
			return
		}
		targets = append(targets, target)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		respondError(w, http.StatusInternalServerError, "failed to scan folders")
		return
	}
	rows.Close()
	if len(targets) == 0 {
		respondError(w, http.StatusNotFound, "folder not found")
		return
	}
	for i := range targets {
		targets[i].inboxID, err = a.ensureFolder(r.Context(), targets[i].mailboxID, "Inbox")
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to load inbox")
			return
		}
	}
	tx, err := a.db.BeginTx(r.Context(), nil)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete folder")
		return
	}
	defer tx.Rollback()
	now := a.now().UTC().Format(time.RFC3339Nano)
	moved := 0
	for _, target := range targets {
		messageRows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, target.mailboxID, target.folderID)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to load folder messages")
			return
		}
		messageIDs := []string{}
		for messageRows.Next() {
			var messageID string
			if err := messageRows.Scan(&messageID); err != nil {
				messageRows.Close()
				respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
				return
			}
			messageIDs = append(messageIDs, messageID)
		}
		if err := messageRows.Err(); err != nil {
			messageRows.Close()
			respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
			return
		}
		messageRows.Close()
		for _, messageID := range messageIDs {
			meta, err := a.nextIMAPMetadata(r.Context(), tx, target.inboxID)
			if err != nil {
				respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
				return
			}
			if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, target.inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
				respondError(w, http.StatusInternalServerError, "failed to move folder messages")
				return
			}
			moved++
		}
		if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, target.folderID, target.mailboxID); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to delete folder")
			return
		}
	}
	if err := tx.Commit(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete folder")
		return
	}
	for _, target := range targets {
		_, _ = a.bumpFolderModSeq(r.Context(), target.inboxID)
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": moved})
}

func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name, icon string) (string, error) {
	var existingID string
	err := a.db.QueryRowContext(ctx, `SELECT id FROM folders WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name).Scan(&existingID)
	if err == nil && (strings.TrimSpace(icon) == "" || strings.EqualFold(strings.TrimSpace(icon), "auto")) {
		return existingID, nil
	}
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return "", err
	}
	id, err := a.ensureFolder(ctx, mailboxID, name)
	if err != nil {
		return "", err
	}
	_, err = a.db.ExecContext(ctx, `UPDATE folders SET icon=? WHERE id=? AND mailbox_id=?`, folderIconForName(name, icon), id, mailboxID)
	return id, err
}

func folderIconForName(name, requested string) string {
	if icon := strings.TrimSpace(requested); icon != "" && !strings.EqualFold(icon, "auto") {
		return normalizeFolderIcon(icon)
	}
	value := strings.ToLower(strings.TrimSpace(name))
	for _, match := range []struct {
		icon  string
		terms []string
	}{
		{"netflix", []string{"netflix", "奈飞", "网飞"}},
		{"chatgpt", []string{"chatgpt", "openai", "gpt"}},
		{"receipt", []string{"账单", "发票", "收据", "bill", "invoice", "receipt"}},
		{"shopping", []string{"购物", "订单", "快递", "shop", "order", "delivery"}},
		{"plane", []string{"旅行", "旅游", "机票", "酒店", "travel", "trip", "flight", "hotel"}},
		{"graduation", []string{"学习", "教育", "课程", "学校", "study", "school", "course"}},
		{"users", []string{"联系人", "团队", "用户", "contact", "team", "people"}},
		{"briefcase", []string{"工作", "项目", "客户", "work", "project", "business", "client"}},
		{"heart", []string{"收藏", "喜欢", "favorite", "favourite"}},
		{"star", []string{"重要", "紧急", "important", "urgent"}},
		{"shield", []string{"安全", "验证", "密码", "登录", "security", "verify", "password", "login"}},
		{"bell", []string{"提醒", "通知", "remind", "notification"}},
		{"mail", []string{"邮件", "邮箱", "mail", "email"}},
	} {
		for _, term := range match.terms {
			if folderNameContainsTerm(value, term) {
				return match.icon
			}
		}
	}
	return "folder"
}

func folderNameContainsTerm(value, term string) bool {
	if term != "gpt" {
		return strings.Contains(value, term)
	}
	for _, token := range strings.FieldsFunc(value, func(r rune) bool {
		return (r < 'a' || r > 'z') && (r < '0' || r > '9')
	}) {
		if token == term {
			return true
		}
	}
	return false
}

func normalizeFolderIcon(raw string) string {
	icon := strings.TrimSpace(raw)
	const customPrefix = "data:image/png;base64,"
	if strings.HasPrefix(icon, customPrefix) {
		data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(icon, customPrefix))
		config, configErr := png.DecodeConfig(bytes.NewReader(data))
		validDimensions := config.Width > 0 && config.Width <= 128 && config.Height > 0 && config.Height <= 128
		if err == nil && configErr == nil && validDimensions && len(data) <= 32*1024 {
			return icon
		}
		return "folder"
	}
	icon = strings.ToLower(icon)
	switch icon {
	case "folder", "mail", "briefcase", "users", "receipt", "shopping", "plane", "graduation", "heart", "star", "bell", "shield", "tag", "netflix", "chatgpt":
		return icon
	default:
		return "folder"
	}
}

func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
	var maxOrder int
	if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')`, mailboxID).Scan(&maxOrder); err != nil {
		return 0, err
	}
	if maxOrder < customFolderDefaultSortOrderBase {
		maxOrder = customFolderDefaultSortOrderBase
	}
	return maxOrder + 1, nil
}

func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		user := currentUser(r)
		if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
			labelName, ok := a.labelNameForUser(r.Context(), labelID, user.ID)
			if !ok {
				respondError(w, http.StatusNotFound, "label not found")
				return
			}
			a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id=m.id AND lower(l.name)=lower(?))`, []any{user.ID, labelName})
			return
		}
		folder := r.URL.Query().Get("folder")
		if folder == "" {
			folder = "Inbox"
		}
		if normalized, err := normalizeFolderNameForUser(folder); err != nil {
			badRequest(w, err)
			return
		} else {
			folder = normalized
		}
		a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?`, []any{user.ID, folder})
		return
	}
	mb, err := a.mailboxForCurrentUser(r)
	if err != nil {
		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"
	}
	if normalized, err := normalizeFolderNameForUser(folder); err != nil {
		badRequest(w, err)
		return
	} else {
		folder = normalized
	}
	folderID, err := a.ensureFolder(r.Context(), mb.ID, folder)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folder")
		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) {
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		user := currentUser(r)
		a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1`, []any{user.ID})
		return
	}
	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 {
		offset = 0
	}
	limit := mailMessagesPageSize

	if q != "" {
		where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
		like := "%" + q + "%"
		args = append(args, like, like, like, like, like, like, like, like)
	}
	var err error
	where, args, err = appendMailMessageSearchFilters(r, where, args)
	if err != nil {
		badRequest(w, err)
		return
	}
	args = append(args, limit+1, offset)
	query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),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")
		return
	}
	defer rows.Close()
	items := []MailMessage{}
	for rows.Next() {
		msg, err := scanMessageSummary(rows)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan messages")
			return
		}
		items = append(items, msg)
	}
	next := ""
	if len(items) > limit {
		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 appendMailMessageSearchFilters(r *http.Request, where string, args []any) (string, []any, error) {
	if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
		where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)`
		like := "%" + from + "%"
		args = append(args, like, like)
	}
	if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
		where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)`
		like := "%" + to + "%"
		args = append(args, like, like, like, like)
	}
	if subject := strings.TrimSpace(r.URL.Query().Get("subject")); subject != "" {
		where += ` AND m.subject LIKE ?`
		args = append(args, "%"+subject+"%")
	}
	switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachmentMode"))) {
	case "with":
		where += ` AND m.has_attachments=1`
	case "without":
		where += ` AND m.has_attachments=0`
	default:
		if mailSearchFlag(r, "hasAttachments") {
			where += ` AND m.has_attachments=1`
		}
	}
	if minSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("minSizeKb"), "minSizeKb"); err != nil {
		return where, args, err
	} else if ok {
		where += ` AND m.size_bytes>=?`
		args = append(args, minSize)
	}
	if maxSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("maxSizeKb"), "maxSizeKb"); err != nil {
		return where, args, err
	} else if ok {
		where += ` AND m.size_bytes<=?`
		args = append(args, maxSize)
	}
	switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("readStatus"))) {
	case "read":
		where += ` AND m.is_read=1`
	case "unread":
		where += ` AND m.is_read=0`
	default:
		if mailSearchFlag(r, "unread") {
			where += ` AND m.is_read=0`
		}
	}
	switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flagStatus"))) {
	case "starred":
		where += ` AND m.is_starred=1`
	case "unstarred":
		where += ` AND m.is_starred=0`
	default:
		if mailSearchFlag(r, "starred") {
			where += ` AND m.is_starred=1`
		}
	}
	if start, ok, err := mailSearchDateBoundary(r.URL.Query().Get("startDate"), false); err != nil {
		return where, args, err
	} else if ok {
		where += ` AND m.received_at>=?`
		args = append(args, start)
	}
	if end, ok, err := mailSearchDateBoundary(r.URL.Query().Get("endDate"), true); err != nil {
		return where, args, err
	} else if ok {
		where += ` AND m.received_at<=?`
		args = append(args, end)
	}
	return where, args, nil
}

func mailSearchFlag(r *http.Request, key string) bool {
	switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
	case "1", "true", "yes", "on":
		return true
	default:
		return false
	}
}

func mailSearchDateBoundary(value string, endOfDay bool) (string, bool, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "", false, nil
	}
	if len(value) == len("2006-01-02") {
		t, err := time.Parse("2006-01-02", value)
		if err != nil {
			return "", false, fmt.Errorf("invalid date %q", value)
		}
		if endOfDay {
			t = t.AddDate(0, 0, 1).Add(-time.Nanosecond)
		}
		return t.UTC().Format(time.RFC3339Nano), true, nil
	}
	t, err := time.Parse(time.RFC3339Nano, value)
	if err != nil {
		return "", false, fmt.Errorf("invalid date %q", value)
	}
	return t.UTC().Format(time.RFC3339Nano), true, nil
}

func mailSearchSizeBytes(value string, key string) (int64, bool, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0, false, nil
	}
	kb, err := strconv.ParseInt(value, 10, 64)
	if err != nil || kb < 0 {
		return 0, false, fmt.Errorf("invalid %s %q", key, value)
	}
	return kb * 1024, true, nil
}

func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
	if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
		labels, err := a.labelsForUser(r.Context(), currentUser(r).ID)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to load labels")
			return
		}
		respondJSON(w, http.StatusOK, map[string]any{"items": labels})
		return
	}
	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) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) {
	mb, err := a.mailboxForCurrentUser(r)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	labelID := chi.URLParam(r, "id")
	if labelID == "" {
		badRequest(w, fmt.Errorf("label id is required"))
		return
	}
	ctx := r.Context()
	if !a.labelBelongsToMailbox(ctx, labelID, mb.ID) {
		respondError(w, http.StatusNotFound, "label not found")
		return
	}
	tx, err := a.db.BeginTx(ctx, nil)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to begin transaction")
		return
	}
	defer tx.Rollback()
	if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ?", labelID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to remove label associations")
		return
	}
	result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete label")
		return
	}
	if n, _ := result.RowsAffected(); n == 0 {
		respondError(w, http.StatusNotFound, "label not found")
		return
	}
	if err := tx.Commit(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to commit transaction")
		return
	}
	labels, err := a.labelsForMailbox(ctx, mb.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) 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 {
		respondError(w, http.StatusNotFound, "message not found")
		return
	}
	if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) {
		read := true
		if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
			a.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err)
		}
		if modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID); err == nil && modSeq > 0 {
			_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?`, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
		} else {
			_, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
		}
		msg.IsRead = true
	}
	respondJSON(w, http.StatusOK, msg)
}

type mailComposeInput struct {
	MailboxID   string            `json:"mailboxId"`
	From        string            `json:"from"`
	FromName    string            `json:"fromName"`
	To          []string          `json:"to"`
	CC          []string          `json:"cc"`
	BCC         []string          `json:"bcc"`
	Subject     string            `json:"subject"`
	Text        string            `json:"text"`
	HTML        string            `json:"html"`
	Attachments []AttachmentInput `json:"attachments"`
}

type mailDraftInput struct {
	MailboxID   string             `json:"mailboxId"`
	To          []string           `json:"to"`
	CC          []string           `json:"cc"`
	BCC         []string           `json:"bcc"`
	Subject     string             `json:"subject"`
	Text        string             `json:"text"`
	HTML        string             `json:"html"`
	Attachments *[]AttachmentInput `json:"attachments"`
}

type scheduledSendPayload struct {
	MailboxID   string            `json:"mailboxId"`
	From        string            `json:"from"`
	FromName    string            `json:"fromName"`
	To          []string          `json:"to"`
	CC          []string          `json:"cc"`
	BCC         []string          `json:"bcc"`
	Subject     string            `json:"subject"`
	Text        string            `json:"text"`
	HTML        string            `json:"html"`
	Attachments []AttachmentInput `json:"attachments"`
	DraftID     string            `json:"draftId,omitempty"`
}

type ScheduledSend struct {
	ID        string     `json:"id"`
	MailboxID string     `json:"mailboxId"`
	DraftID   string     `json:"draftId,omitempty"`
	Subject   string     `json:"subject"`
	To        []string   `json:"to"`
	Snippet   string     `json:"snippet"`
	SendAt    time.Time  `json:"sendAt"`
	Status    string     `json:"status"`
	Error     string     `json:"error,omitempty"`
	CreatedAt time.Time  `json:"createdAt"`
	UpdatedAt time.Time  `json:"updatedAt"`
	SentAt    *time.Time `json:"sentAt,omitempty"`
}

func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
	var req mailComposeInput
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req)
	if err != nil {
		if errors.Is(err, errNoRecipients) {
			badRequest(w, err)
			return
		}
		if errors.Is(err, errInvalidMIME) {
			badRequest(w, err)
			return
		}
		if errors.Is(err, errAttachmentTooLarge) {
			badRequest(w, err)
			return
		}
		if errors.Is(err, errSMTPRateLimited) {
			respondError(w, http.StatusTooManyRequests, err.Error())
			return
		}
		if errors.Is(err, errSenderNotAuthorized) {
			respondError(w, http.StatusForbidden, err.Error())
			return
		}
		if errors.Is(err, errMailboxQuotaExceeded) {
			respondError(w, http.StatusInsufficientStorage, err.Error())
			return
		}
		respondError(w, http.StatusInternalServerError, err.Error())
		return
	}
	respondJSON(w, http.StatusCreated, msg)
}

var errNoRecipients = errors.New("at least one recipient is required")
var errInvalidMIME = errors.New("invalid mime message")
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
var errSenderNotAuthorized = errors.New("sender address is not authorized")
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")

func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
	return a.sendMailWithSource(ctx, user, mb, req, sendSourceWebmail)
}

func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput, source string) (*MailMessage, error) {
	source = strings.TrimSpace(source)
	if source == "" {
		source = sendSourceWebmail
	}
	if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
		return nil, err
	}
	req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC)
	allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
	if len(allRecipients) == 0 {
		return nil, errNoRecipients
	}
	if strings.TrimSpace(req.Subject) == "" {
		req.Subject = "(no subject)"
	}
	req.HTML = a.policy.Sanitize(req.HTML)
	if strings.TrimSpace(req.Text) == "" {
		req.Text = stripTags(req.HTML)
	}
	if strings.TrimSpace(req.HTML) == "" {
		req.HTML = "<p>" + htmlEscape(req.Text) + "</p>"
	}

	now := a.now().UTC()
	fromAddress, fromName, err := a.authorizedSender(ctx, mb, req.From)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(req.FromName) != "" && normalizeEmail(req.From) == normalizeEmail(mb.Address) {
		fromName = strings.TrimSpace(req.FromName)
	}
	messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1])
	mimeBytes, err := BuildMIME(MIMEMessage{
		From: fromAddress, FromName: fromName, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, MessageID: messageID, Date: now, Attachments: req.Attachments,
	})
	if err != nil {
		return nil, fmt.Errorf("%w: %v", errInvalidMIME, err)
	}
	if err := a.recordSMTPRate(ctx, user, mb); err != nil {
		return nil, err
	}

	sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent")
	if err != nil {
		return nil, fmt.Errorf("failed to load sent folder: %w", err)
	}
	base := storedMessage{MailboxID: mb.ID, FolderID: sentFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: req.Subject, From: fromAddress, FromName: fromName, To: req.To, CC: req.CC, BCC: req.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(req.Text, req.HTML), BodyText: req.Text, BodyHTML: req.HTML, IsRead: true}
	sentID, err := a.insertMessage(ctx, base, req.Attachments)
	if err != nil {
		return nil, fmt.Errorf("failed to store sent message: %w", err)
	}
	if err := a.writeRawMessageToMaildir(ctx, sentID, mimeBytes, false); err != nil {
		a.deleteMessage(ctx, sentID)
		return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
	}
	a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
	if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
		a.deleteMessage(ctx, sentID)
		return nil, fmt.Errorf("failed to enqueue delivery: %w", err)
	}

	// Development/local-domain delivery: known local recipients go to their Inbox.
	// When catch-all is enabled, unknown local recipients are stored as unregistered
	// messages visible only in the admin "全部邮件" view.
	localRecipients := append(req.To, req.CC...)
	localRecipients = append(localRecipients, req.BCC...)
	for _, rcpt := range localRecipients {
		rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
		if err != nil {
			if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
				continue
			}
			copyMsg := base
			copyMsg.MailboxID = ""
			copyMsg.FolderID = ""
			copyMsg.RecipientAddr = normalizeEmail(rcpt)
			copyMsg.MessageUID = newID("uid")
			copyMsg.IsRead = false
			if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
				_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
				a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
			}
			continue
		}
		if rcptMailbox.Status != "active" {
			if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
				copyMsg := base
				copyMsg.MailboxID = ""
				copyMsg.FolderID = ""
				copyMsg.RecipientAddr = normalizeEmail(rcpt)
				copyMsg.MessageUID = newID("uid")
				copyMsg.IsRead = false
				if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
					_ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments)
					a.enqueueTelegramMailNotification(ctx, copyID, copyMsg, req.Attachments)
				}
			}
			continue
		}
		inboxID, err := a.ensureFolder(ctx, rcptMailbox.ID, "Inbox")
		if err != nil {
			continue
		}
		copyMsg := base
		copyMsg.MailboxID = rcptMailbox.ID
		copyMsg.FolderID = inboxID
		copyMsg.RecipientAddr = normalizeEmail(rcpt)
		copyMsg.MessageUID = newID("uid")
		copyMsg.IsRead = false
		if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
			_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
			a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
			a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
			if a.shouldNotifyTelegramMessage(ctx, inboxMsgID) {
				a.enqueueTelegramMailNotification(ctx, inboxMsgID, copyMsg, req.Attachments)
			}
		}
	}

	msg, _ := a.messageByID(ctx, sentID, true)
	return msg, nil
}

func userLimits(user *User) PermissionLimits {
	if user == nil {
		return defaultPermissionLimits()
	}
	return user.Limits
}

func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error {
	limitBytes := attachmentLimitBytes(limits)
	if limitBytes == 0 {
		return nil
	}
	for _, att := range attachments {
		decodedLen, err := decodedBase64Len(att.ContentBase64)
		if err != nil {
			return fmt.Errorf("%w: %v", errInvalidMIME, err)
		}
		if decodedLen > limitBytes {
			return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB)
		}
	}
	return nil
}

func decodedBase64Len(value string) (int64, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0, nil
	}
	data, err := base64.StdEncoding.DecodeString(value)
	if err != nil {
		return 0, err
	}
	return int64(len(data)), nil
}

var errIMAPRateLimited = errors.New("imap rate limit exceeded")
var errPOP3RateLimited = errors.New("pop3 rate limit exceeded")

func (a *App) checkAndRecordProtocolRate(ctx context.Context, user *User, mb *Mailbox, table string, dailyLimit, minuteLimit int) error {
	if dailyLimit == 0 && minuteLimit == 0 {
		return nil
	}
	now := a.now().UTC()
	tx, err := a.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if dailyLimit > 0 {
		var count int
		if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
			return err
		}
		if count >= dailyLimit {
			return fmt.Errorf("daily limit %d", dailyLimit)
		}
	}
	if minuteLimit > 0 {
		var count int
		if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
			return err
		}
		if count >= minuteLimit {
			return fmt.Errorf("per-minute limit %d", minuteLimit)
		}
	}
	if _, err := tx.ExecContext(ctx, "INSERT INTO "+table+"(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)", newID("evt"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
		return err
	}
	return tx.Commit()
}

func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Login        string `json:"login"`
		Protocol     string `json:"protocol"`
		Username     string `json:"username"`
		IP           string `json:"ip"`
		Remote       string `json:"remote"`
		Success      *bool  `json:"success"`
		PolicyReject *bool  `json:"policy_reject"`
	}
	if err := decodeJSON(r, &req); err != nil {
		respondJSON(w, http.StatusOK, map[string]int{"status": 0})
		return
	}
	var user *User
	var mailbox *Mailbox
	login := normalizeEmail(req.Login)
	if login == "" {
		login = normalizeEmail(req.Username)
	}
	if login != "" {
		if mb, err := a.mailboxByAddress(r.Context(), login); err == nil {
			mailbox = mb
			user, _ = a.userByID(r.Context(), mb.UserID)
		}
		if user == nil {
			var passHash string
			user, passHash, _ = a.userByEmail(r.Context(), login)
			_ = passHash
		}
	}
	if user == nil || user.Disabled {
		respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": "user not found or disabled"})
		return
	}
	if user.Role == "admin" {
		respondJSON(w, http.StatusOK, map[string]int{"status": 0})
		return
	}
	limits := user.Limits
	var err error
	switch strings.ToLower(req.Protocol) {
	case "imap":
		if limits.IMAPMinuteLimit > 0 {
			if mailbox == nil {
				err = errors.New("mailbox not found")
			} else {
				err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "imap_events", 0, limits.IMAPMinuteLimit)
			}
		}
	case "pop3":
		if limits.POP3MinuteLimit > 0 {
			if mailbox == nil {
				err = errors.New("mailbox not found")
			} else {
				err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "pop3_events", 0, limits.POP3MinuteLimit)
			}
		}
	}
	if err != nil {
		respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": err.Error()})
	} else {
		respondJSON(w, http.StatusOK, map[string]int{"status": 0})
	}
}

func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error {
	if user == nil || mb == nil || user.Role == "admin" {
		return nil
	}
	limits := user.Limits
	if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 {
		return nil
	}
	now := a.now().UTC()
	tx, err := a.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if limits.SMTPDailyLimit > 0 {
		var count int
		if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
			return err
		}
		if count >= limits.SMTPDailyLimit {
			return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit)
		}
	}
	if limits.SMTPMinuteLimit > 0 {
		var count int
		if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
			return err
		}
		if count >= limits.SMTPMinuteLimit {
			return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit)
		}
	}
	if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
		return err
	}
	return tx.Commit()
}

func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
	var req mailDraftInput
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	if req.Attachments != nil {
		if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil {
			if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
				badRequest(w, err)
				return
			}
			respondError(w, http.StatusInternalServerError, err.Error())
			return
		}
	}
	compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML}
	compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
	subject := strings.TrimSpace(compose.Subject)
	if subject == "" {
		subject = "(无主题)"
	}
	compose.HTML = a.policy.Sanitize(compose.HTML)
	if strings.TrimSpace(compose.Text) == "" {
		compose.Text = stripTags(compose.HTML)
	}
	if strings.TrimSpace(compose.HTML) == "" && strings.TrimSpace(compose.Text) != "" {
		compose.HTML = "<p>" + htmlEscape(compose.Text) + "</p>"
	}

	now := a.now().UTC()
	draftID := strings.TrimSpace(chi.URLParam(r, "id"))
	draftsFolderID, err := a.ensureFolder(r.Context(), mb.ID, "Drafts")
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load drafts folder")
		return
	}
	if draftID == "" {
		messageID := fmt.Sprintf("<%s@%s>", newID("draft"), strings.Split(mb.Address, "@")[1])
		attachments := []AttachmentInput{}
		if req.Attachments != nil {
			attachments = *req.Attachments
		}
		stored := storedMessage{MailboxID: mb.ID, FolderID: draftsFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: subject, From: mb.Address, FromName: mb.DisplayName, To: compose.To, CC: compose.CC, BCC: compose.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(compose.Text, compose.HTML), BodyText: compose.Text, BodyHTML: compose.HTML, IsRead: true}
		draftID, err = a.insertMessage(r.Context(), stored, attachments)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to save draft")
			return
		}
		if err := a.writeStoredMessageToMaildir(r.Context(), draftID, stored, attachments); err != nil {
			a.deleteMessage(r.Context(), draftID)
			respondError(w, http.StatusInternalServerError, "failed to save draft")
			return
		}
		msg, _ := a.messageByID(r.Context(), draftID, true)
		respondJSON(w, http.StatusCreated, msg)
		return
	}

	existing, err := a.loadMessageForRequest(r, draftID, false)
	if err != nil || !strings.EqualFold(existing.Folder, "Drafts") || existing.MailboxID != mb.ID {
		respondError(w, http.StatusNotFound, "draft not found")
		return
	}
	size := int64(len(compose.Text) + len(compose.HTML))
	hasAttachments := existing.HasAttachments
	if req.Attachments != nil {
		a.deleteMessageFiles(r.Context(), draftID)
		if _, err := a.db.ExecContext(r.Context(), `DELETE FROM attachments WHERE message_id=?`, draftID); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to replace draft attachments")
			return
		}
		hasAttachments = len(*req.Attachments) > 0
		for _, att := range *req.Attachments {
			if decoded, err := base64.StdEncoding.DecodeString(att.ContentBase64); err == nil {
				size += int64(len(decoded))
			}
		}
	} else {
		var attachmentBytes int64
		_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes)
		size += attachmentBytes
	}
	modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update draft")
		return
	}
	_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
		subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, modSeq, modSeq, now.Format(time.RFC3339Nano), draftID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update draft")
		return
	}
	if req.Attachments != nil {
		for _, att := range *req.Attachments {
			if err := a.storeAttachment(r.Context(), draftID, att); err != nil {
				respondError(w, http.StatusInternalServerError, "failed to store draft attachment")
				return
			}
		}
	}
	if err := a.rewriteMessageMaildir(r.Context(), draftID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update draft")
		return
	}
	msg, _ := a.messageByID(r.Context(), draftID, true)
	respondJSON(w, http.StatusOK, msg)
}

func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
	msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
	if err != nil || !strings.EqualFold(msg.Folder, "Drafts") {
		respondError(w, http.StatusNotFound, "draft not found")
		return
	}
	a.deleteMessageMaildirFile(r.Context(), msg.ID)
	a.deleteMessageFiles(r.Context(), msg.ID)
	if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete draft")
		return
	}
	_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
	args := []any{user.ID}
	where := `user_id=?`
	if !isAllMailboxID(mailboxID) {
		mb, err := a.mailboxForCurrentUser(r)
		if err != nil {
			respondError(w, http.StatusNotFound, "mailbox not found")
			return
		}
		where += ` AND mailbox_id=?`
		args = append(args, mb.ID)
	}
	args = append(args, "pending", "sending", "failed")
	rows, err := a.db.QueryContext(r.Context(), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at
		FROM scheduled_sends
		WHERE `+where+` AND status IN (?,?,?)
		ORDER BY send_at ASC, created_at DESC`, args...)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
		return
	}
	defer rows.Close()
	items := []ScheduledSend{}
	for rows.Next() {
		var item ScheduledSend
		var draftID, errorText, sentAt sql.NullString
		var payloadJSON, sendAt, createdAt, updatedAt string
		if err := rows.Scan(&item.ID, &item.MailboxID, &draftID, &payloadJSON, &sendAt, &item.Status, &errorText, &createdAt, &updatedAt, &sentAt); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan scheduled sends")
			return
		}
		if draftID.Valid {
			item.DraftID = draftID.String
		}
		if errorText.Valid {
			item.Error = errorText.String
		}
		item.SendAt = parseTime(sendAt)
		item.CreatedAt = parseTime(createdAt)
		item.UpdatedAt = parseTime(updatedAt)
		item.SentAt = nullableTime(sentAt)
		applyScheduledSendPreview(&item, payloadJSON)
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"items": items})
}

func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
	status := strings.TrimSpace(r.URL.Query().Get("status"))
	if strings.EqualFold(status, "all") {
		status = ""
	}
	cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
	if err != nil {
		badRequest(w, err)
		return
	}
	limit := 30
	args := []any{user.ID}
	where := `mb.user_id=?`
	if !isAllMailboxID(mailboxID) {
		mb, err := a.mailboxForCurrentUser(r)
		if err != nil {
			respondError(w, http.StatusNotFound, "mailbox not found")
			return
		}
		where += ` AND sq.mailbox_id=?`
		args = append(args, mb.ID)
	}
	if status != "" {
		if !validSendQueueStatus(status) {
			badRequest(w, errors.New("invalid send queue status"))
			return
		}
		where += ` AND sq.status=?`
		args = append(args, status)
	}
	if messageID := strings.TrimSpace(r.URL.Query().Get("messageId")); messageID != "" {
		where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)`
		args = append(args, messageID, messageID, messageID)
	}
	if recipient := normalizeEmail(r.URL.Query().Get("recipient")); recipient != "" {
		where += ` AND sq.recipients_json LIKE ?`
		args = append(args, "%"+recipient+"%")
	}
	if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
		t, err := parseTimeQuery(from)
		if err != nil {
			badRequest(w, errors.New("invalid from time"))
			return
		}
		where += ` AND sq.created_at>=?`
		args = append(args, t.UTC().Format(time.RFC3339Nano))
	}
	if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
		t, err := parseTimeQuery(to)
		if err != nil {
			badRequest(w, errors.New("invalid to time"))
			return
		}
		where += ` AND sq.created_at<=?`
		args = append(args, t.UTC().Format(time.RFC3339Nano))
	}
	if cursorCreatedAt != "" && cursorID != "" {
		where += ` AND (sq.created_at < ? OR (sq.created_at = ? AND sq.id < ?))`
		args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
	}
	args = append(args, limit+1)
	query := `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
		FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE ` + where + ` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ?`
	if offsetCursor > 0 {
		args = append(args, offsetCursor)
		query += ` OFFSET ?`
	}
	rows, err := a.db.QueryContext(r.Context(), query, args...)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send queue")
		return
	}
	defer rows.Close()
	items := []SendQueueEntry{}
	for rows.Next() {
		item, err := scanSendQueueEntry(rows)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan send queue")
			return
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send queue")
		return
	}
	next := ""
	if len(items) > limit {
		items = items[:limit]
		last := items[len(items)-1]
		next = encodeSendQueueCursor(last.CreatedAt, last.ID)
	}
	respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}

func (a *App) handleSendQueueAudit(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	id := strings.TrimSpace(chi.URLParam(r, "id"))
	if !a.sendQueueBelongsToUser(r.Context(), id, user.ID) {
		respondError(w, http.StatusNotFound, "send queue item not found")
		return
	}
	rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at
		FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC`, id)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send audit")
		return
	}
	defer rows.Close()
	items := []SendAuditEvent{}
	for rows.Next() {
		var item SendAuditEvent
		var recipientsJSON, createdAt string
		if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
			respondError(w, http.StatusInternalServerError, "failed to scan send audit")
			return
		}
		item.Recipients = jsonDecodeSlice(recipientsJSON)
		item.CreatedAt = parseTime(createdAt)
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send audit")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"items": items})
}

func (a *App) handleRetrySendQueue(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	id := strings.TrimSpace(chi.URLParam(r, "id"))
	item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
	if err != nil {
		respondError(w, http.StatusNotFound, "send queue item not found")
		return
	}
	if item.Status != sendQueueStatusFailed {
		badRequest(w, errors.New("send queue item is not failed"))
		return
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
		sendQueueStatusQueued, now, now, id, sendQueueStatusFailed, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to retry send queue item")
		return
	}
	if n, _ := res.RowsAffected(); n == 0 {
		respondError(w, http.StatusNotFound, "send queue item not found")
		return
	}
	a.deleteSendQueueDeliveredMarker(id)
	a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInput{
		QueueID:       item.ID,
		UserID:        user.ID,
		MailboxID:     item.MailboxID,
		SentMessageID: item.SentMessageID,
		Source:        item.Source,
		MailFrom:      item.MailFrom,
		HeaderFrom:    item.HeaderFrom,
		Recipients:    item.Recipients,
	})
	updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send queue item")
		return
	}
	respondJSON(w, http.StatusOK, updated)
}

func (a *App) handleCancelSendQueue(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	id := strings.TrimSpace(chi.URLParam(r, "id"))
	item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
	if err != nil {
		respondError(w, http.StatusNotFound, "send queue item not found")
		return
	}
	if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
		badRequest(w, errors.New("send queue item cannot be canceled"))
		return
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
		sendQueueStatusCanceled, now, id, sendQueueStatusQueued, sendQueueStatusFailed, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to cancel send queue item")
		return
	}
	if n, _ := res.RowsAffected(); n == 0 {
		respondError(w, http.StatusNotFound, "send queue item not found")
		return
	}
	a.deleteSendQueueDeliveredMarker(id)
	a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInput{
		QueueID:       item.ID,
		UserID:        user.ID,
		MailboxID:     item.MailboxID,
		SentMessageID: item.SentMessageID,
		Source:        item.Source,
		MailFrom:      item.MailFrom,
		HeaderFrom:    item.HeaderFrom,
		Recipients:    item.Recipients,
	})
	updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load send queue item")
		return
	}
	respondJSON(w, http.StatusOK, updated)
}

type sendQueueEntryScanner interface{ Scan(dest ...any) error }

func scanSendQueueEntry(row sendQueueEntryScanner) (SendQueueEntry, error) {
	var item SendQueueEntry
	var recipientsJSON, nextAttemptAt, createdAt, updatedAt string
	var deliveredAt sql.NullString
	err := row.Scan(&item.ID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Subject, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Status, &item.AttemptCount, &item.MaxAttempts, &nextAttemptAt, &item.LastError, &createdAt, &updatedAt, &deliveredAt)
	if err != nil {
		return item, err
	}
	item.Recipients = jsonDecodeSlice(recipientsJSON)
	item.NextAttemptAt = parseTime(nextAttemptAt)
	item.CreatedAt = parseTime(createdAt)
	item.UpdatedAt = parseTime(updatedAt)
	item.DeliveredAt = nullableTime(deliveredAt)
	return item, nil
}

func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) (SendQueueEntry, error) {
	row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
		FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?`, id, userID)
	return scanSendQueueEntry(row)
}

type sendQueueCursor struct {
	CreatedAt string `json:"createdAt"`
	ID        string `json:"id"`
}

func encodeSendQueueCursor(createdAt time.Time, id string) string {
	payload, _ := json.Marshal(sendQueueCursor{CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), ID: id})
	return base64.RawURLEncoding.EncodeToString(payload)
}

func parseSendQueueCursor(raw string) (createdAt string, id string, offset int, err error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", "", 0, nil
	}
	if n, convErr := strconv.Atoi(raw); convErr == nil {
		if n < 0 {
			return "", "", 0, errors.New("invalid cursor")
		}
		return "", "", n, nil
	}
	data, decodeErr := base64.RawURLEncoding.DecodeString(raw)
	if decodeErr != nil {
		return "", "", 0, errors.New("invalid cursor")
	}
	var cursor sendQueueCursor
	if err := json.Unmarshal(data, &cursor); err != nil {
		return "", "", 0, errors.New("invalid cursor")
	}
	t, err := parseTimeQuery(cursor.CreatedAt)
	if err != nil || strings.TrimSpace(cursor.ID) == "" {
		return "", "", 0, errors.New("invalid cursor")
	}
	return t.UTC().Format(time.RFC3339Nano), strings.TrimSpace(cursor.ID), 0, nil
}

func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
	var count int
	_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count)
	return count > 0
}

func parseTimeQuery(raw string) (time.Time, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return time.Time{}, errors.New("time is required")
	}
	if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
		return t, nil
	}
	if t, err := time.Parse("2006-01-02", raw); err == nil {
		return t, nil
	}
	return time.Time{}, errors.New("invalid time")
}

func validSendQueueStatus(status string) bool {
	switch status {
	case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
		return true
	default:
		return false
	}
}

func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
	var req struct {
		MailboxID   string            `json:"mailboxId"`
		To          []string          `json:"to"`
		CC          []string          `json:"cc"`
		BCC         []string          `json:"bcc"`
		Subject     string            `json:"subject"`
		Text        string            `json:"text"`
		HTML        string            `json:"html"`
		Attachments []AttachmentInput `json:"attachments"`
		DraftID     string            `json:"draftId"`
		SendAt      string            `json:"sendAt"`
	}
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
	if err != nil {
		respondError(w, http.StatusNotFound, "mailbox not found")
		return
	}
	sendAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.SendAt))
	if err != nil {
		badRequest(w, errors.New("sendAt is required"))
		return
	}
	if !sendAt.After(a.now().Add(30 * time.Second)) {
		badRequest(w, errors.New("sendAt must be in the future"))
		return
	}
	compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments}
	if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil {
		if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
			badRequest(w, err)
			return
		}
		respondError(w, http.StatusInternalServerError, err.Error())
		return
	}
	compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
	if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 {
		badRequest(w, errNoRecipients)
		return
	}
	compose.HTML = a.policy.Sanitize(compose.HTML)
	if strings.TrimSpace(compose.Text) == "" {
		compose.Text = stripTags(compose.HTML)
	}
	if strings.TrimSpace(compose.HTML) == "" {
		compose.HTML = "<p>" + htmlEscape(compose.Text) + "</p>"
	}
	if strings.TrimSpace(compose.Subject) == "" {
		compose.Subject = "(no subject)"
	}
	draftID := strings.TrimSpace(req.DraftID)
	if draftID != "" {
		msg, err := a.loadMessageForRequest(r, draftID, false)
		if err != nil || !strings.EqualFold(msg.Folder, "Drafts") || msg.MailboxID != mb.ID {
			respondError(w, http.StatusNotFound, "draft not found")
			return
		}
	}
	now := a.now().UTC().Format(time.RFC3339Nano)
	payload := scheduledSendPayload{MailboxID: compose.MailboxID, From: compose.From, FromName: compose.FromName, To: compose.To, CC: compose.CC, BCC: compose.BCC, Subject: compose.Subject, Text: compose.Text, HTML: compose.HTML, Attachments: compose.Attachments, DraftID: draftID}
	item := ScheduledSend{ID: newID("sched"), MailboxID: mb.ID, DraftID: draftID, Subject: payload.Subject, To: payload.To, Snippet: snippetFrom(payload.Text, payload.HTML), SendAt: sendAt.UTC(), Status: "pending", CreatedAt: parseTime(now), UpdatedAt: parseTime(now)}
	if _, err := a.db.ExecContext(r.Context(), `INSERT INTO scheduled_sends(id,user_id,mailbox_id,draft_id,payload_json,send_at,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, item.ID, currentUser(r).ID, mb.ID, nullableString(draftID), jsonEncode(payload), item.SendAt.Format(time.RFC3339Nano), item.Status, now, now); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to schedule send")
		return
	}
	respondJSON(w, http.StatusCreated, item)
}

func applyScheduledSendPreview(item *ScheduledSend, payloadJSON string) {
	var payload scheduledSendPayload
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		item.Subject = "(no subject)"
		return
	}
	item.Subject = strings.TrimSpace(payload.Subject)
	if item.Subject == "" {
		item.Subject = "(no subject)"
	}
	item.To = payload.To
	item.Snippet = snippetFrom(payload.Text, payload.HTML)
}

func (a *App) handleCancelScheduledSend(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	id := strings.TrimSpace(chi.URLParam(r, "id"))
	var status string
	if err := a.db.QueryRowContext(r.Context(), `SELECT status FROM scheduled_sends WHERE id=? AND user_id=?`, id, user.ID).Scan(&status); err != nil {
		respondError(w, http.StatusNotFound, "scheduled send not found")
		return
	}
	if status != "pending" && status != "failed" {
		badRequest(w, errors.New("scheduled send is not pending"))
		return
	}
	if _, err := a.db.ExecContext(r.Context(), `UPDATE scheduled_sends SET status='cancelled',updated_at=? WHERE id=? AND user_id=?`, a.now().UTC().Format(time.RFC3339Nano), id, user.ID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to cancel scheduled send")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) scheduledSendWorker(ctx context.Context) {
	a.log.Info("scheduled send worker started")
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	for {
		if err := a.processDueScheduledSends(ctx); err != nil {
			a.log.Warn("scheduled send worker failed", "error", err)
		}
		select {
		case <-ctx.Done():
			a.log.Info("scheduled send worker stopped")
			return
		case <-ticker.C:
		}
	}
}

func (a *App) smtpEventsCleanupWorker(ctx context.Context) {
	a.log.Info("smtp events cleanup worker started")
	ticker := time.NewTicker(10 * time.Minute)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			a.log.Info("smtp events cleanup worker stopped")
			return
		case <-ticker.C:
			a.cleanupStaleEvents(ctx)
		}
	}
}

func (a *App) cleanupStaleEvents(ctx context.Context) {
	cutoff := a.now().UTC().Add(-24 * time.Hour).Format(time.RFC3339Nano)
	for _, table := range []string{"smtp_send_events", "imap_events", "pop3_events"} {
		result, err := a.db.ExecContext(ctx, "DELETE FROM "+table+" WHERE created_at<?", cutoff)
		if err != nil {
			a.log.Warn("event cleanup failed", "table", table, "error", err)
		} else if n, _ := result.RowsAffected(); n > 0 {
			a.log.Debug("event cleanup deleted rows", "table", table, "count", n)
		}
	}
}

func (a *App) processDueScheduledSends(ctx context.Context) error {
	rows, err := a.db.QueryContext(ctx, `SELECT id,mailbox_id,draft_id,payload_json FROM scheduled_sends WHERE status='pending' AND send_at<=? ORDER BY send_at LIMIT 20`, a.now().UTC().Format(time.RFC3339Nano))
	if err != nil {
		return err
	}
	defer rows.Close()
	type dueItem struct {
		id, mailboxID, draftID, payloadJSON string
	}
	items := []dueItem{}
	for rows.Next() {
		var item dueItem
		var draftID sql.NullString
		if err := rows.Scan(&item.id, &item.mailboxID, &draftID, &item.payloadJSON); err != nil {
			return err
		}
		if draftID.Valid {
			item.draftID = draftID.String
		}
		items = append(items, item)
	}
	for _, item := range items {
		a.processScheduledSend(ctx, item.id, item.mailboxID, item.draftID, item.payloadJSON)
	}
	return rows.Err()
}

func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID, payloadJSON string) {
	now := a.now().UTC().Format(time.RFC3339Nano)
	res, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sending',updated_at=? WHERE id=? AND status='pending'`, now, id)
	if err != nil {
		a.log.Warn("failed to claim scheduled send", "id", id, "error", err)
		return
	}
	if affected, _ := res.RowsAffected(); affected == 0 {
		return
	}
	var payload scheduledSendPayload
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		a.markScheduledSendFailed(ctx, id, "invalid scheduled payload")
		return
	}
	mb, err := a.mailboxByID(ctx, mailboxID)
	if err != nil || mb.Status != "active" {
		a.markScheduledSendFailed(ctx, id, "mailbox not found")
		return
	}
	user, err := a.userByID(ctx, mb.UserID)
	if err != nil || !userHasPermission(user, PermissionMailSchedule) || !userHasPermission(user, PermissionMailSend) {
		a.markScheduledSendFailed(ctx, id, "mail send permission revoked")
		return
	}
	compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments}
	if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil {
		a.markScheduledSendFailed(ctx, id, err.Error())
		return
	}
	if draftID != "" {
		a.deleteMessage(ctx, draftID)
	}
	sentAt := a.now().UTC().Format(time.RFC3339Nano)
	if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil {
		a.log.Warn("failed to mark scheduled send sent", "id", id, "error", err)
	}
}

func (a *App) markScheduledSendFailed(ctx context.Context, id, message string) {
	if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='failed',error=?,updated_at=? WHERE id=?`, message, a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
		a.log.Warn("failed to mark scheduled send failed", "id", id, "error", err)
	}
}

func (a *App) isLocalDomainAddress(ctx context.Context, address string) bool {
	parts := strings.Split(normalizeEmail(address), "@")
	if len(parts) != 2 || parts[1] == "" {
		return false
	}
	var count int
	_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM domains WHERE name=? AND status='active'`, parts[1]).Scan(&count)
	return count > 0
}

func (a *App) handleMarkRead(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 {
		Read *bool `json:"read"`
	}
	_ = decodeJSON(r, &req)
	read := true
	if req.Read != nil {
		read = *req.Read
	}
	if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(read), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true, "read": read})
}

func (a *App) handleStar(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 {
		Starred *bool `json:"starred"`
	}
	_ = decodeJSON(r, &req)
	starred := !msg.IsStarred
	if req.Starred != nil {
		starred = *req.Starred
	}
	if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, nil, &starred); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	_, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(starred), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to update message")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true, "starred": starred})
}

func (a *App) handleMove(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 {
		Folder string `json:"folder"`
	}
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	folder, err := normalizeFolderNameForUser(req.Folder)
	if err != nil {
		badRequest(w, err)
		return
	}
	folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, folder)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to load folder")
		return
	}
	if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
		respondError(w, http.StatusInternalServerError, "failed to move message")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) handleBulkMove(w http.ResponseWriter, r *http.Request) {
	var req struct {
		IDs    []string `json:"ids"`
		Folder string   `json:"folder"`
	}
	if err := decodeJSON(r, &req); err != nil {
		badRequest(w, err)
		return
	}
	folder, err := normalizeFolderNameForUser(req.Folder)
	if err != nil {
		badRequest(w, err)
		return
	}
	ids := make([]string, 0, len(req.IDs))
	seen := map[string]bool{}
	for _, id := range req.IDs {
		id = strings.TrimSpace(id)
		if id == "" || seen[id] {
			continue
		}
		seen[id] = true
		ids = append(ids, id)
	}
	if len(ids) == 0 {
		badRequest(w, errors.New("请选择要移动的邮件"))
		return
	}
	type itemResult struct {
		ID        string `json:"id"`
		MailboxID string `json:"mailboxId,omitempty"`
		OK        bool   `json:"ok"`
		Message   string `json:"message"`
	}
	results := make([]itemResult, 0, len(ids))
	folderByMailbox := map[string]string{}
	moved := 0
	for _, id := range ids {
		msg, err := a.loadMessageForRequest(r, id, false)
		if err != nil {
			results = append(results, itemResult{ID: id, OK: false, Message: "邮件不存在或无权访问"})
			continue
		}
		folderID := folderByMailbox[msg.MailboxID]
		if folderID == "" {
			folderID, err = a.ensureFolder(r.Context(), msg.MailboxID, folder)
			if err != nil {
				results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "目标文件夹创建失败"})
				continue
			}
			folderByMailbox[msg.MailboxID] = folderID
		}
		if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
			a.log.Warn("bulk move message failed", "messageID", msg.ID, "mailboxID", msg.MailboxID, "folder", folder, "error", err)
			results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: false, Message: "移动失败，请稍后重试"})
			continue
		}
		moved++
		results = append(results, itemResult{ID: id, MailboxID: msg.MailboxID, OK: true, Message: "已移动"})
	}
	failed := len(results) - moved
	message := fmt.Sprintf("已移动 %d 封邮件", moved)
	if failed > 0 {
		message = fmt.Sprintf("已移动 %d 封邮件，%d 封失败", moved, failed)
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": failed == 0, "moved": moved, "failed": failed, "message": message, "items": results})
}

func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
	row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,f.icon,
		COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
		COUNT(m.id) AS total,
		f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
		FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
		WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.icon,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
	var f MailFolder
	if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.Icon, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
		return nil, err
	}
	return &f, nil
}

func normalizeCustomFolderName(raw string) (string, error) {
	name := strings.Join(strings.Fields(raw), " ")
	if name == "" {
		return "", errors.New("folder name is required")
	}
	if len([]rune(name)) > 48 {
		return "", errors.New("folder name is too long")
	}
	if strings.ContainsAny(name, `/\:`) || strings.Contains(name, "..") {
		return "", errors.New("folder name contains invalid characters")
	}
	for _, r := range name {
		if r < 32 || r == 127 {
			return "", errors.New("folder name contains invalid characters")
		}
	}
	return name, nil
}

func normalizeFolderNameForUser(raw string) (string, error) {
	name := strings.TrimSpace(raw)
	if isSystemFolderName(name) {
		switch strings.ToLower(name) {
		case "inbox":
			return "Inbox", nil
		case "sent":
			return "Sent", nil
		case "drafts":
			return "Drafts", nil
		case "archive":
			return "Archive", nil
		case "spam":
			return "Spam", nil
		case "trash":
			return "Trash", nil
		}
	}
	return normalizeCustomFolderName(raw)
}

func isSystemFolderName(name string) bool {
	switch strings.ToLower(strings.TrimSpace(name)) {
	case "inbox", "sent", "drafts", "archive", "spam", "trash":
		return true
	default:
		return false
	}
}

func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
	msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
	if err != nil {
		respondError(w, http.StatusNotFound, "message not found")
		return
	}
	if strings.EqualFold(msg.Folder, "Trash") {
		a.deleteMessageMaildirFile(r.Context(), msg.ID)
		a.deleteMessageFiles(r.Context(), msg.ID)
		_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID)
		if err == nil {
			_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
		}
	} else {
		trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
		if e != nil {
			err = e
		} else {
			if e := a.moveMessageMaildir(r.Context(), msg.ID, trashID); e != nil {
				err = e
			}
		}
	}
	if err != nil {
		respondError(w, http.StatusInternalServerError, "failed to delete message")
		return
	}
	respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
	user := currentUser(r)
	attID := chi.URLParam(r, "id")
	row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path
		FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id
		WHERE a.id=? AND mb.user_id=?`, attID, user.ID)
	var filename, contentType, path string
	var size int64
	if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
		respondError(w, http.StatusNotFound, "attachment not found")
		return
	}
	f, err := os.Open(path)
	if err != nil {
		respondError(w, http.StatusNotFound, "attachment file missing")
		return
	}
	defer f.Close()
	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
	_, _ = io.Copy(w, f)
}

func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) {
	attID := chi.URLParam(r, "id")
	row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?`, attID)
	var filename, contentType, path, mailboxID string
	var size int64
	if err := row.Scan(&filename, &contentType, &size, &path, &mailboxID); err != nil {
		respondError(w, http.StatusNotFound, "attachment not found")
		return
	}
	user := currentUser(r)
	if mailboxID == "" && (user == nil || user.Role != "admin") {
		respondError(w, http.StatusForbidden, "system admin required")
		return
	}
	f, err := os.Open(path)
	if err != nil {
		respondError(w, http.StatusNotFound, "attachment file missing")
		return
	}
	defer f.Close()
	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
	w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
	_, _ = io.Copy(w, f)
}

func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	flusher, _ := w.(http.Flusher)
	fmt.Fprintf(w, "event: sync\ndata: {\"status\":\"connected\"}\n\n")
	if flusher != nil {
		flusher.Flush()
	}
	ticker := time.NewTicker(25 * time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return
		case t := <-ticker.C:
			fmt.Fprintf(w, "event: heartbeat\ndata: {\"time\":\"%s\"}\n\n", t.UTC().Format(time.RFC3339))
			if flusher != nil {
				flusher.Flush()
			}
		}
	}
}

func (a *App) mailboxForCurrentUser(r *http.Request) (*Mailbox, error) {
	return a.mailboxForCurrentUserWithID(r, r.URL.Query().Get("mailboxId"))
}

func (a *App) mailboxForCurrentUserWithID(r *http.Request, mailboxID string) (*Mailbox, error) {
	user := currentUser(r)
	if user == nil {
		return nil, errors.New("no user")
	}
	mailboxID = strings.TrimSpace(mailboxID)
	if mailboxID != "" {
		row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
			FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, user.ID)
		var m Mailbox
		var created string
		if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
			return nil, err
		}
		m.UserEmail = user.Email
		m.CreatedAt = parseTime(created)
		return &m, nil
	}
	return a.mailboxForUser(r.Context(), user.ID)
}

func (a *App) mailboxByAddress(ctx context.Context, address string) (*Mailbox, error) {
	row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE address=? AND status='active'`, normalizeEmail(address))
	var m Mailbox
	var created string
	if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
		return nil, err
	}
	m.CreatedAt = parseTime(created)
	return &m, nil
}

func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool) (*MailMessage, error) {
	user := currentUser(r)
	row := a.db.QueryRowContext(r.Context(), `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE m.id=? AND mb.user_id=?`, id, user.ID)
	var messageID string
	if err := row.Scan(&messageID); err != nil {
		return nil, err
	}
	return a.messageByID(r.Context(), messageID, includeBody)
}

func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
	row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'')
		FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
	msg, err := scanMessageFull(row, includeBody)
	if err != nil {
		return nil, err
	}
	if includeBody {
		atts, err := a.attachmentsForMessage(ctx, id)
		if err != nil {
			return nil, err
		}
		msg.Attachments = atts
	}
	labels, err := a.labelsForMessage(ctx, id)
	if err != nil {
		return nil, err
	}
	msg.Labels = labels
	if includeBody {
		_ = a.db.QueryRowContext(ctx, `SELECT id,status FROM send_queue WHERE sent_message_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, id).Scan(&msg.SendQueueID, &msg.SendQueueStatus)
	}
	return &msg, nil
}

func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, error) {
	return a.insertMessageWithDB(ctx, a.db, msg, attachments)
}

type dbExecutor interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

type dbQueryer interface {
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg storedMessage, attachments []AttachmentInput) (string, error) {
	id := newID("mail")
	now := a.now().UTC().Format(time.RFC3339Nano)
	hasAttachments := len(attachments) > 0
	size := int64(len(msg.BodyText) + len(msg.BodyHTML))
	for _, att := range attachments {
		if decoded, err := base64.StdEncoding.DecodeString(att.ContentBase64); err == nil {
			size += int64(len(decoded))
		}
	}
	if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil {
		return "", err
	}
	var mailboxID, folderID any
	if strings.TrimSpace(msg.MailboxID) != "" {
		mailboxID = msg.MailboxID
	}
	if strings.TrimSpace(msg.FolderID) != "" {
		folderID = msg.FolderID
	}
	imapUID, imapModSeq := int64(0), int64(1)
	if strings.TrimSpace(msg.FolderID) != "" {
		meta, err := a.nextIMAPMetadata(ctx, db, msg.FolderID)
		if err != nil {
			return "", err
		}
		imapUID, imapModSeq = meta.UID, meta.ModSeq
	}
	recipientAddr := normalizeEmail(msg.RecipientAddr)
	auth := normalizeMailAuthentication(msg.Authentication)
	_, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at)
		VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, auth.AuthenticationResults, auth.SPF, auth.DKIM, auth.DMARC, auth.ReceivedSPF, msg.RawPath, imapUID, imapModSeq, now, now)
	if err != nil {
		return "", err
	}
	for _, att := range attachments {
		if err := a.storeAttachmentWithDB(ctx, db, id, att); err != nil {
			a.deleteMessageFiles(ctx, id)
			return "", err
		}
	}
	return id, nil
}

func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error {
	mailboxID = strings.TrimSpace(mailboxID)
	if mailboxID == "" || addBytes <= 0 {
		return nil
	}
	rowDB, ok := db.(dbQueryer)
	if !ok {
		return nil
	}
	var quotaMB int64
	var userID string
	if err := rowDB.QueryRowContext(ctx, `SELECT u.storage_quota_mb,mb.user_id FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&quotaMB, &userID); err != nil {
		return err
	}
	if quotaMB <= 0 {
		return nil
	}
	var used int64
	if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(m.size_bytes),0) FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE mb.user_id=?`, userID).Scan(&used); err != nil {
		return err
	}
	quotaBytes := quotaMB * 1024 * 1024
	if used+addBytes > quotaBytes {
		return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes)
	}
	return nil
}

func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error {
	return a.storeAttachmentWithDB(ctx, a.db, messageID, input)
}

func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageID string, input AttachmentInput) error {
	filename := filepath.Base(strings.TrimSpace(input.Filename))
	if filename == "." || filename == "" {
		filename = "attachment.bin"
	}
	contentType := input.ContentType
	if contentType == "" {
		contentType = "application/octet-stream"
	}
	data, err := base64.StdEncoding.DecodeString(input.ContentBase64)
	if err != nil {
		return err
	}
	dir := filepath.Join(a.config().DataDir, "attachments", messageID)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	id := newID("att")
	path := filepath.Join(dir, id+"_"+filename)
	if err := os.WriteFile(path, data, 0o600); err != nil {
		return err
	}
	_, err = db.ExecContext(ctx, `INSERT INTO attachments(id,message_id,filename,content_type,size_bytes,storage_path,created_at) VALUES(?,?,?,?,?,?,?)`, id, messageID, filename, contentType, len(data), path, a.now().UTC().Format(time.RFC3339Nano))
	return err
}

func (a *App) attachmentsForMessage(ctx context.Context, messageID string) ([]Attachment, error) {
	rows, err := a.db.QueryContext(ctx, `SELECT id,message_id,filename,content_type,size_bytes,created_at FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := []Attachment{}
	for rows.Next() {
		var item Attachment
		var created string
		if err := rows.Scan(&item.ID, &item.MessageID, &item.Filename, &item.ContentType, &item.SizeBytes, &created); err != nil {
			return nil, err
		}
		item.CreatedAt = parseTime(created)
		items = append(items, item)
	}
	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 `+mailLabelOrderSQL("l")+`, 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) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
	rows, err := a.db.QueryContext(ctx, `SELECT MIN(l.id),'',MIN(l.name),MIN(l.color),COUNT(ml.message_id)
		FROM mail_labels l
		JOIN mailboxes mb ON mb.id=l.mailbox_id
		LEFT JOIN message_labels ml ON ml.label_id=l.id
		WHERE mb.user_id=? AND mb.status='active'
		GROUP BY lower(l.name)
		ORDER BY `+mailLabelNameOrderSQL("MIN(l.name)")+`, lower(MIN(l.name))`, userID)
	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 `+mailLabelOrderSQL("l")+`, 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 `+mailLabelOrderSQL("l")+`, 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 mailLabelOrderSQL(alias string) string {
	return mailLabelNameOrderSQL(alias + `.name`)
}

func mailLabelNameOrderSQL(expression string) string {
	return `CASE ` + expression + ` WHEN '个人' THEN 10 WHEN '家人' THEN 20 WHEN '朋友' THEN 30 WHEN '工作' THEN 40 WHEN '重要' THEN 50 ELSE 100 END`
}

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 (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bool {
	var count int
	_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?`, labelID, userID).Scan(&count)
	return count > 0
}

func (a *App) labelNameForUser(ctx context.Context, labelID, userID string) (string, bool) {
	var name string
	if err := a.db.QueryRowContext(ctx, `SELECT l.name FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=? AND mb.status='active'`, labelID, userID).Scan(&name); err != nil {
		return "", false
	}
	return name, true
}

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 {
		return
	}
	defer rows.Close()
	for rows.Next() {
		var p string
		if rows.Scan(&p) == nil {
			_ = os.Remove(p)
		}
	}
	_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
}

func (a *App) deleteMessage(ctx context.Context, messageID string) {
	var folderID sql.NullString
	_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID)
	a.deleteMessageMaildirFile(ctx, messageID)
	a.deleteMessageFiles(ctx, messageID)
	_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID)
	if folderID.Valid && folderID.String != "" {
		_, _ = a.bumpFolderModSeq(ctx, folderID.String)
	}
}

type messageSummaryScanner interface{ Scan(dest ...any) error }

func scanAdminMessageSummary(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.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
	if err != nil {
		return msg, err
	}
	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)
	return msg, nil
}

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.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
	if err != nil {
		return msg, err
	}
	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)
	return msg, nil
}

func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) {
	var msg MailMessage
	var toJSON, ccJSON, bccJSON, sent, received string
	var auth MailAuthentication
	var read, starred, hasAtt int
	var bodyText, bodyHTML string
	err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes, &auth.AuthenticationResults, &auth.SPF, &auth.DKIM, &auth.DMARC, &auth.ReceivedSPF)
	if err != nil {
		return msg, err
	}
	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)
	msg.Authentication = normalizeMailAuthentication(auth)
	if includeBody {
		msg.BodyText, msg.BodyHTML = bodyText, bodyHTML
	}
	return msg, nil
}

func parseMailAuthentication(header textproto.MIMEHeader) MailAuthentication {
	authResults := strings.Join(header.Values("Authentication-Results"), "\n")
	receivedSPF := strings.Join(header.Values("Received-SPF"), "\n")
	auth := MailAuthentication{
		AuthenticationResults: strings.TrimSpace(authResults),
		ReceivedSPF:           strings.TrimSpace(receivedSPF),
		SPF:                   "unknown",
		DKIM:                  "unknown",
		DMARC:                 "unknown",
	}
	for _, value := range header.Values("Authentication-Results") {
		for _, field := range strings.FieldsFunc(value, func(r rune) bool {
			return r == ';' || r == '\r' || r == '\n'
		}) {
			key, result, ok := authMethodResult(field)
			if !ok {
				continue
			}
			switch key {
			case "spf":
				auth.SPF = result
			case "dkim":
				auth.DKIM = result
			case "dmarc":
				auth.DMARC = result
			}
		}
	}
	if auth.SPF == "unknown" {
		for _, value := range header.Values("Received-SPF") {
			if result := firstAuthStatus(value); result != "unknown" {
				auth.SPF = result
				break
			}
		}
	}
	return normalizeMailAuthentication(auth)
}

func authMethodResult(value string) (string, string, bool) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "", "", false
	}
	parts := strings.SplitN(value, "=", 2)
	if len(parts) != 2 {
		return "", "", false
	}
	method := strings.ToLower(strings.TrimSpace(parts[0]))
	if method != "spf" && method != "dkim" && method != "dmarc" {
		return "", "", false
	}
	return method, firstAuthStatus(parts[1]), true
}

func firstAuthStatus(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "unknown"
	}
	value = strings.ToLower(strings.Fields(value)[0])
	if idx := strings.IndexAny(value, "();,"); idx >= 0 {
		value = value[:idx]
	}
	switch value {
	case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
		return value
	default:
		return "unknown"
	}
}

func normalizeMailAuthentication(auth MailAuthentication) MailAuthentication {
	auth.AuthenticationResults = strings.TrimSpace(auth.AuthenticationResults)
	auth.ReceivedSPF = strings.TrimSpace(auth.ReceivedSPF)
	auth.SPF = normalizeAuthStatus(auth.SPF)
	auth.DKIM = normalizeAuthStatus(auth.DKIM)
	auth.DMARC = normalizeAuthStatus(auth.DMARC)
	return auth
}

func normalizeAuthStatus(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none":
		return strings.ToLower(strings.TrimSpace(value))
	default:
		return "unknown"
	}
}
