feat(mail): 增强发送队列筛选与投递追踪能力
- 发送队列支持按 Message-ID、收件人和时间范围筛选,并改为稳定游标分页。 - 邮件详情补充关联的发送队列信息,支持从邮件直接查看投递时间线。 - 前端同步接入新筛选条件,并在发送队列页提供清除筛选入口。
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -1541,6 +1542,137 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
a.cfg.SMTPHost = "127.0.0.1"
|
||||||
|
a.cfg.SMTPPort = "25"
|
||||||
|
ts := httptest.NewServer(a.Router())
|
||||||
|
defer ts.Close()
|
||||||
|
client := &testClient{t: t, server: ts}
|
||||||
|
|
||||||
|
var login map[string]any
|
||||||
|
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||||
|
t.Fatalf("login code=%d", code)
|
||||||
|
}
|
||||||
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
|
now := a.now().UTC()
|
||||||
|
sentFolderID, err := a.ensureFolder(context.Background(), mb.ID, "Sent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentMsg := storedMessage{
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
FolderID: sentFolderID,
|
||||||
|
MessageUID: "uid-queue-detail",
|
||||||
|
MessageID: "<queue-detail@example.test>",
|
||||||
|
Subject: "queue detail",
|
||||||
|
From: mb.Address,
|
||||||
|
To: []string{"detail@example.test"},
|
||||||
|
SentAt: now,
|
||||||
|
ReceivedAt: now,
|
||||||
|
Snippet: "detail",
|
||||||
|
BodyText: "detail",
|
||||||
|
IsRead: true,
|
||||||
|
}
|
||||||
|
sentID, err := a.insertMessage(context.Background(), sentMsg, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
firstID, err := a.enqueueSend(context.Background(), sendQueueInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
SentMessageID: sentID,
|
||||||
|
MessageID: "<queue-detail@example.test>",
|
||||||
|
Source: sendSourceWebmail,
|
||||||
|
MailFrom: mb.Address,
|
||||||
|
HeaderFrom: mb.Address,
|
||||||
|
Recipients: []string{"detail@example.test"},
|
||||||
|
MIMEBytes: []byte("Subject: detail\r\n\r\nbody"),
|
||||||
|
Now: now.Add(-2 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secondID, err := a.enqueueSend(context.Background(), sendQueueInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
MessageID: "<queue-other@example.test>",
|
||||||
|
Source: sendSourceWebmail,
|
||||||
|
MailFrom: mb.Address,
|
||||||
|
HeaderFrom: mb.Address,
|
||||||
|
Recipients: []string{"other@example.test"},
|
||||||
|
MIMEBytes: []byte("Subject: other\r\n\r\nbody"),
|
||||||
|
Now: now.Add(-1 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
thirdID, err := a.enqueueSend(context.Background(), sendQueueInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
MessageID: "<queue-latest@example.test>",
|
||||||
|
Source: sendSourceWebmail,
|
||||||
|
MailFrom: mb.Address,
|
||||||
|
HeaderFrom: mb.Address,
|
||||||
|
Recipients: []string{"latest@example.test"},
|
||||||
|
MIMEBytes: []byte("Subject: latest\r\n\r\nbody"),
|
||||||
|
Now: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 28; i++ {
|
||||||
|
if _, err := a.enqueueSend(context.Background(), sendQueueInput{
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
MessageID: fmt.Sprintf("<queue-extra-%02d@example.test>", i),
|
||||||
|
Source: sendSourceWebmail,
|
||||||
|
MailFrom: mb.Address,
|
||||||
|
HeaderFrom: mb.Address,
|
||||||
|
Recipients: []string{fmt.Sprintf("extra-%02d@example.test", i)},
|
||||||
|
MIMEBytes: []byte("Subject: extra\r\n\r\nbody"),
|
||||||
|
Now: now.Add(time.Duration(-24-i) * time.Hour),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var byMessage struct {
|
||||||
|
Items []SendQueueEntry `json:"items"`
|
||||||
|
}
|
||||||
|
if code := client.do("GET", "/api/mail/send-queue?messageId="+url.QueryEscape("<queue-detail@example.test>"), nil, &byMessage); code != http.StatusOK || len(byMessage.Items) != 1 || byMessage.Items[0].ID != firstID {
|
||||||
|
t.Fatalf("message filter code=%d items=%+v", code, byMessage.Items)
|
||||||
|
}
|
||||||
|
var byRecipient struct {
|
||||||
|
Items []SendQueueEntry `json:"items"`
|
||||||
|
}
|
||||||
|
if code := client.do("GET", "/api/mail/send-queue?recipient="+url.QueryEscape("other@example.test"), nil, &byRecipient); code != http.StatusOK || len(byRecipient.Items) != 1 || byRecipient.Items[0].ID != secondID {
|
||||||
|
t.Fatalf("recipient filter code=%d items=%+v", code, byRecipient.Items)
|
||||||
|
}
|
||||||
|
var byTime struct {
|
||||||
|
Items []SendQueueEntry `json:"items"`
|
||||||
|
}
|
||||||
|
from := now.Add(-90 * time.Minute).Format(time.RFC3339Nano)
|
||||||
|
to := now.Add(30 * time.Minute).Format(time.RFC3339Nano)
|
||||||
|
if code := client.do("GET", "/api/mail/send-queue?from="+url.QueryEscape(from)+"&to="+url.QueryEscape(to), nil, &byTime); code != http.StatusOK || len(byTime.Items) != 2 || byTime.Items[0].ID != thirdID || byTime.Items[1].ID != secondID {
|
||||||
|
t.Fatalf("time filter code=%d items=%+v", code, byTime.Items)
|
||||||
|
}
|
||||||
|
var firstPage struct {
|
||||||
|
Items []SendQueueEntry `json:"items"`
|
||||||
|
NextCursor string `json:"nextCursor"`
|
||||||
|
}
|
||||||
|
if code := client.do("GET", "/api/mail/send-queue?cursor=0", nil, &firstPage); code != http.StatusOK || len(firstPage.Items) != 30 || firstPage.NextCursor == "" {
|
||||||
|
t.Fatalf("first page code=%d cursor=%q items=%+v", code, firstPage.NextCursor, firstPage.Items)
|
||||||
|
}
|
||||||
|
if _, _, _, err := parseSendQueueCursor(firstPage.NextCursor); err != nil {
|
||||||
|
t.Fatalf("next cursor is not stable cursor: %q err=%v", firstPage.NextCursor, err)
|
||||||
|
}
|
||||||
|
var detail MailMessage
|
||||||
|
if code := client.do("GET", "/api/mail/messages/"+sentID+"?markRead=0", nil, &detail); code != http.StatusOK || detail.SendQueueID != firstID || detail.SendQueueStatus == "" {
|
||||||
|
t.Fatalf("message detail queue link code=%d detail=%+v", code, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
|
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
host, port, received := startCapturingSMTP(t, 1)
|
host, port, received := startCapturingSMTP(t, 1)
|
||||||
|
|||||||
@@ -1164,9 +1164,10 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
||||||
if cursor < 0 {
|
if err != nil {
|
||||||
cursor = 0
|
badRequest(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
limit := 30
|
limit := 30
|
||||||
args := []any{user.ID, mb.ID}
|
args := []any{user.ID, mb.ID}
|
||||||
@@ -1179,9 +1180,44 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
where += ` AND sq.status=?`
|
where += ` AND sq.status=?`
|
||||||
args = append(args, status)
|
args = append(args, status)
|
||||||
}
|
}
|
||||||
args = append(args, limit+1, cursor)
|
if messageID := strings.TrimSpace(r.URL.Query().Get("messageId")); messageID != "" {
|
||||||
rows, err := a.db.QueryContext(r.Context(), `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
|
where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)`
|
||||||
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 ? OFFSET ?`, args...)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||||
return
|
return
|
||||||
@@ -1203,7 +1239,8 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
next := ""
|
next := ""
|
||||||
if len(items) > limit {
|
if len(items) > limit {
|
||||||
items = items[:limit]
|
items = items[:limit]
|
||||||
next = strconv.Itoa(cursor + limit)
|
last := items[len(items)-1]
|
||||||
|
next = encodeSendQueueCursor(last.CreatedAt, last.ID)
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||||
}
|
}
|
||||||
@@ -1349,12 +1386,62 @@ func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string)
|
|||||||
return scanSendQueueEntry(row)
|
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 {
|
func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
|
||||||
var count int
|
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)
|
_ = 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
|
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 {
|
func validSendQueueStatus(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
|
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
|
||||||
@@ -1915,6 +2002,9 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
msg.Labels = labels
|
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
|
return &msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,35 +77,37 @@ type MailLabel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MailMessage struct {
|
type MailMessage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
MailboxID string `json:"mailboxId,omitempty"`
|
MailboxID string `json:"mailboxId,omitempty"`
|
||||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||||
FolderID string `json:"folderId"`
|
FolderID string `json:"folderId"`
|
||||||
Folder string `json:"folder"`
|
Folder string `json:"folder"`
|
||||||
MessageUID string `json:"messageUid"`
|
MessageUID string `json:"messageUid"`
|
||||||
IMAPUID int64 `json:"imapUid"`
|
IMAPUID int64 `json:"imapUid"`
|
||||||
IMAPModSeq int64 `json:"imapModseq"`
|
IMAPModSeq int64 `json:"imapModseq"`
|
||||||
MessageID string `json:"messageId"`
|
MessageID string `json:"messageId"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
FromName string `json:"fromName,omitempty"`
|
FromName string `json:"fromName,omitempty"`
|
||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
CC []string `json:"cc"`
|
CC []string `json:"cc"`
|
||||||
BCC []string `json:"bcc,omitempty"`
|
BCC []string `json:"bcc,omitempty"`
|
||||||
SentAt time.Time `json:"sentAt"`
|
SentAt time.Time `json:"sentAt"`
|
||||||
ReceivedAt time.Time `json:"receivedAt"`
|
ReceivedAt time.Time `json:"receivedAt"`
|
||||||
Snippet string `json:"snippet"`
|
Snippet string `json:"snippet"`
|
||||||
BodyText string `json:"bodyText,omitempty"`
|
BodyText string `json:"bodyText,omitempty"`
|
||||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||||
IsRead bool `json:"isRead"`
|
IsRead bool `json:"isRead"`
|
||||||
IsStarred bool `json:"isStarred"`
|
IsStarred bool `json:"isStarred"`
|
||||||
HasAttachments bool `json:"hasAttachments"`
|
HasAttachments bool `json:"hasAttachments"`
|
||||||
SizeBytes int64 `json:"sizeBytes"`
|
SizeBytes int64 `json:"sizeBytes"`
|
||||||
Labels []MailLabel `json:"labels,omitempty"`
|
Labels []MailLabel `json:"labels,omitempty"`
|
||||||
Attachments []Attachment `json:"attachments,omitempty"`
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
Authentication MailAuthentication `json:"authentication"`
|
Authentication MailAuthentication `json:"authentication"`
|
||||||
|
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||||
|
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MailAuthentication struct {
|
type MailAuthentication struct {
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type MailLabel = { id: string; mailboxId?: string; name: string; color: s
|
|||||||
export type MailMessage = {
|
export type MailMessage = {
|
||||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||||
labels?: MailLabel[]
|
labels?: MailLabel[]
|
||||||
|
sendQueueId?: string
|
||||||
|
sendQueueStatus?: SendQueueStatus
|
||||||
}
|
}
|
||||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||||
|
|||||||
@@ -151,11 +151,15 @@ export const api = {
|
|||||||
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||||
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
|
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
|
||||||
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => {
|
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string; messageId?: string; recipient?: string; from?: string; to?: string } = {}) => {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||||
if (params.status && params.status !== "all") query.set("status", params.status)
|
if (params.status && params.status !== "all") query.set("status", params.status)
|
||||||
if (params.cursor) query.set("cursor", params.cursor)
|
if (params.cursor) query.set("cursor", params.cursor)
|
||||||
|
if (params.messageId) query.set("messageId", params.messageId)
|
||||||
|
if (params.recipient) query.set("recipient", params.recipient)
|
||||||
|
if (params.from) query.set("from", params.from)
|
||||||
|
if (params.to) query.set("to", params.to)
|
||||||
const suffix = query.toString()
|
const suffix = query.toString()
|
||||||
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
||||||
},
|
},
|
||||||
|
|||||||
+80
-21
@@ -108,6 +108,10 @@ export function MailPage() {
|
|||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
const [cancelingScheduledId, setCancelingScheduledId] = React.useState("")
|
||||||
const [sendQueueStatus, setSendQueueStatus] = React.useState<SendQueueStatus | "all">("all")
|
const [sendQueueStatus, setSendQueueStatus] = React.useState<SendQueueStatus | "all">("all")
|
||||||
|
const [sendQueueMessageId, setSendQueueMessageId] = React.useState("")
|
||||||
|
const [sendQueueRecipient, setSendQueueRecipient] = React.useState("")
|
||||||
|
const [sendQueueFrom, setSendQueueFrom] = React.useState("")
|
||||||
|
const [sendQueueTo, setSendQueueTo] = React.useState("")
|
||||||
const [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
|
const [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
|
||||||
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
|
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||||
@@ -143,7 +147,12 @@ export function MailPage() {
|
|||||||
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") })
|
||||||
const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
|
const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 })
|
||||||
const canViewSendQueue = canReadMail
|
const canViewSendQueue = canReadMail
|
||||||
const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000 })
|
const sendQueue = useQuery({
|
||||||
|
queryKey: ["send-queue", activeMailboxId, sendQueueStatus, sendQueueMessageId, sendQueueRecipient, sendQueueFrom, sendQueueTo],
|
||||||
|
queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus, messageId: sendQueueMessageId.trim(), recipient: sendQueueRecipient.trim(), from: datetimeLocalToISO(sendQueueFrom), to: datetimeLocalToISO(sendQueueTo) }),
|
||||||
|
enabled: !!activeMailboxId && canViewSendQueue,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
})
|
||||||
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
|
const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue })
|
||||||
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||||
const inboxProbe = useQuery({
|
const inboxProbe = useQuery({
|
||||||
@@ -530,10 +539,7 @@ export function MailPage() {
|
|||||||
: scheduledItems
|
: scheduledItems
|
||||||
const sendQueueItems = sendQueue.data?.items || []
|
const sendQueueItems = sendQueue.data?.items || []
|
||||||
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length
|
||||||
const sendQueueQuery = query.trim().toLowerCase()
|
const visibleSendQueueItems = sendQueueItems
|
||||||
const visibleSendQueueItems = sendQueueQuery
|
|
||||||
? sendQueueItems.filter((item) => [item.subject, item.source, item.lastError, item.error, item.failureReason, ...(item.recipients || [])].join(" ").toLowerCase().includes(sendQueueQuery))
|
|
||||||
: sendQueueItems
|
|
||||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue)
|
||||||
const labelItems = labels.data?.items || []
|
const labelItems = labels.data?.items || []
|
||||||
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
const selectedLabel = labelItems.find((item) => item.id === selectedLabelId)
|
||||||
@@ -837,6 +843,10 @@ export function MailPage() {
|
|||||||
setMailFilter("all")
|
setMailFilter("all")
|
||||||
setMobileSidebarOpen(false)
|
setMobileSidebarOpen(false)
|
||||||
}
|
}
|
||||||
|
function openMessageSendTimeline(message: MailMessage) {
|
||||||
|
if (!message.sendQueueId) return
|
||||||
|
setSendQueueAuditId(message.sendQueueId)
|
||||||
|
}
|
||||||
function openLabel(labelId: string) {
|
function openLabel(labelId: string) {
|
||||||
setSelectedLabelId(labelId)
|
setSelectedLabelId(labelId)
|
||||||
setMailView("label")
|
setMailView("label")
|
||||||
@@ -1077,10 +1087,18 @@ export function MailPage() {
|
|||||||
items={visibleSendQueueItems}
|
items={visibleSendQueueItems}
|
||||||
total={sendQueueItems.length}
|
total={sendQueueItems.length}
|
||||||
loading={sendQueue.isLoading}
|
loading={sendQueue.isLoading}
|
||||||
query={query}
|
|
||||||
status={sendQueueStatus}
|
status={sendQueueStatus}
|
||||||
|
messageId={sendQueueMessageId}
|
||||||
|
recipient={sendQueueRecipient}
|
||||||
|
from={sendQueueFrom}
|
||||||
|
to={sendQueueTo}
|
||||||
pendingId={sendQueuePendingId}
|
pendingId={sendQueuePendingId}
|
||||||
onStatusChange={setSendQueueStatus}
|
onStatusChange={setSendQueueStatus}
|
||||||
|
onMessageIdChange={setSendQueueMessageId}
|
||||||
|
onRecipientChange={setSendQueueRecipient}
|
||||||
|
onFromChange={setSendQueueFrom}
|
||||||
|
onToChange={setSendQueueTo}
|
||||||
|
onClearFilters={() => { setSendQueueMessageId(""); setSendQueueRecipient(""); setSendQueueFrom(""); setSendQueueTo(""); setSendQueueStatus("all") }}
|
||||||
onRetry={(item) => retrySendQueue.mutate(item)}
|
onRetry={(item) => retrySendQueue.mutate(item)}
|
||||||
onCancel={(item) => cancelSendQueue.mutate(item)}
|
onCancel={(item) => cancelSendQueue.mutate(item)}
|
||||||
onAudit={(item) => setSendQueueAuditId(item.id)}
|
onAudit={(item) => setSendQueueAuditId(item.id)}
|
||||||
@@ -1115,6 +1133,7 @@ export function MailPage() {
|
|||||||
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
|
onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })}
|
||||||
onReply={openReply}
|
onReply={openReply}
|
||||||
onForward={openForward}
|
onForward={openForward}
|
||||||
|
onSendTimeline={openMessageSendTimeline}
|
||||||
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
|
onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })}
|
||||||
onDelete={confirmDeleteMessage}
|
onDelete={confirmDeleteMessage}
|
||||||
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
|
onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })}
|
||||||
@@ -1169,6 +1188,7 @@ export function MailPage() {
|
|||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
{canSendMail && <Button variant="outline" size="sm" onClick={() => openReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||||
{canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
{canSendMail && <Button variant="outline" size="sm" onClick={() => openForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||||
|
{selected.sendQueueId && <Button variant="outline" size="sm" onClick={() => openMessageSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</Button>}
|
||||||
{canOrganizeMail && (selected.folder === "Archive" ? (
|
{canOrganizeMail && (selected.folder === "Archive" ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}>取消归档</Button>
|
<Button variant="outline" size="sm" onClick={() => move.mutate({ id: selected.id, folder: "Inbox" })}>取消归档</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1563,10 +1583,18 @@ function SendQueueView({
|
|||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
query,
|
|
||||||
status,
|
status,
|
||||||
|
messageId,
|
||||||
|
recipient,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
pendingId,
|
pendingId,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onMessageIdChange,
|
||||||
|
onRecipientChange,
|
||||||
|
onFromChange,
|
||||||
|
onToChange,
|
||||||
|
onClearFilters,
|
||||||
onRetry,
|
onRetry,
|
||||||
onCancel,
|
onCancel,
|
||||||
onAudit,
|
onAudit,
|
||||||
@@ -1576,31 +1604,49 @@ function SendQueueView({
|
|||||||
items: SendQueueItem[]
|
items: SendQueueItem[]
|
||||||
total: number
|
total: number
|
||||||
loading: boolean
|
loading: boolean
|
||||||
query: string
|
|
||||||
status: SendQueueStatus | "all"
|
status: SendQueueStatus | "all"
|
||||||
|
messageId: string
|
||||||
|
recipient: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
pendingId: string
|
pendingId: string
|
||||||
onStatusChange: (status: SendQueueStatus | "all") => void
|
onStatusChange: (status: SendQueueStatus | "all") => void
|
||||||
|
onMessageIdChange: (value: string) => void
|
||||||
|
onRecipientChange: (value: string) => void
|
||||||
|
onFromChange: (value: string) => void
|
||||||
|
onToChange: (value: string) => void
|
||||||
|
onClearFilters: () => void
|
||||||
onRetry: (item: SendQueueItem) => void
|
onRetry: (item: SendQueueItem) => void
|
||||||
onCancel: (item: SendQueueItem) => void
|
onCancel: (item: SendQueueItem) => void
|
||||||
onAudit: (item: SendQueueItem) => void
|
onAudit: (item: SendQueueItem) => void
|
||||||
canMutate: boolean
|
canMutate: boolean
|
||||||
}) {
|
}) {
|
||||||
const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空"
|
const hasFilters = status !== "all" || messageId.trim() || recipient.trim() || from || to
|
||||||
|
const empty = hasFilters ? "当前筛选没有匹配的发送任务" : "发送队列为空"
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||||
<div className={cn("flex shrink-0 items-center justify-between gap-3 border-b", compact ? "min-h-12 px-4 py-2" : "h-14 px-5")}>
|
<div className={cn("shrink-0 space-y-3 border-b", compact ? "px-4 py-3" : "px-5 py-4")}>
|
||||||
<div className="min-w-0">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold"><History className="h-4 w-4" />发送队列</div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-muted-foreground">{items.length} / {total} 个发送任务</div>
|
<div className="flex items-center gap-2 text-sm font-semibold"><History className="h-4 w-4" />发送队列</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{items.length} / {total} 个发送任务</div>
|
||||||
|
</div>
|
||||||
|
<Select value={status} onValueChange={(value) => onStatusChange(value as SendQueueStatus | "all")}>
|
||||||
|
<SelectTrigger className="h-9 w-[132px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sendQueueStatusOptions.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className={cn("grid gap-2", compact ? "grid-cols-1" : "grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_160px_160px_auto]")}>
|
||||||
|
<Input value={messageId} onChange={(event) => onMessageIdChange(event.target.value)} placeholder="Message-ID" className="h-9" />
|
||||||
|
<Input value={recipient} onChange={(event) => onRecipientChange(event.target.value)} placeholder="收件人" className="h-9" />
|
||||||
|
<Input type="datetime-local" value={from} onChange={(event) => onFromChange(event.target.value)} className="h-9" />
|
||||||
|
<Input type="datetime-local" value={to} onChange={(event) => onToChange(event.target.value)} className="h-9" />
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-9" disabled={!hasFilters} onClick={onClearFilters}>清除</Button>
|
||||||
</div>
|
</div>
|
||||||
<Select value={status} onValueChange={(value) => onStatusChange(value as SendQueueStatus | "all")}>
|
|
||||||
<SelectTrigger className="h-9 w-[132px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{sendQueueStatusOptions.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
{loading && <ScheduledSendSkeleton />}
|
{loading && <ScheduledSendSkeleton />}
|
||||||
@@ -1723,6 +1769,12 @@ function sendQueueSourceLabel(source: string) {
|
|||||||
return source || "未知"
|
return source || "未知"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function datetimeLocalToISO(value: string) {
|
||||||
|
if (!value) return ""
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete"
|
||||||
|
|
||||||
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {
|
||||||
@@ -1995,6 +2047,7 @@ function CompactMailView({
|
|||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
onForward,
|
onForward,
|
||||||
|
onSendTimeline,
|
||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleRead,
|
onToggleRead,
|
||||||
@@ -2033,6 +2086,7 @@ function CompactMailView({
|
|||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
onForward: (message: MailMessage) => void
|
onForward: (message: MailMessage) => void
|
||||||
|
onSendTimeline: (message: MailMessage) => void
|
||||||
onArchive: (message: MailMessage) => void
|
onArchive: (message: MailMessage) => void
|
||||||
onDelete: (message: MailMessage) => void
|
onDelete: (message: MailMessage) => void
|
||||||
onToggleRead: (message: MailMessage) => void
|
onToggleRead: (message: MailMessage) => void
|
||||||
@@ -2064,6 +2118,7 @@ function CompactMailView({
|
|||||||
onStar={onStar}
|
onStar={onStar}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onForward={onForward}
|
onForward={onForward}
|
||||||
|
onSendTimeline={onSendTimeline}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onToggleRead={onToggleRead}
|
onToggleRead={onToggleRead}
|
||||||
@@ -2126,6 +2181,7 @@ function CompactMessageDetail({
|
|||||||
onStar,
|
onStar,
|
||||||
onReply,
|
onReply,
|
||||||
onForward,
|
onForward,
|
||||||
|
onSendTimeline,
|
||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleRead,
|
onToggleRead,
|
||||||
@@ -2147,6 +2203,7 @@ function CompactMessageDetail({
|
|||||||
onStar: (message: MailMessage) => void
|
onStar: (message: MailMessage) => void
|
||||||
onReply: (message: MailMessage) => void
|
onReply: (message: MailMessage) => void
|
||||||
onForward: (message: MailMessage) => void
|
onForward: (message: MailMessage) => void
|
||||||
|
onSendTimeline: (message: MailMessage) => void
|
||||||
onArchive: (message: MailMessage) => void
|
onArchive: (message: MailMessage) => void
|
||||||
onDelete: (message: MailMessage) => void
|
onDelete: (message: MailMessage) => void
|
||||||
onToggleRead: (message: MailMessage) => void
|
onToggleRead: (message: MailMessage) => void
|
||||||
@@ -2185,6 +2242,7 @@ function CompactMessageDetail({
|
|||||||
<>
|
<>
|
||||||
{canSend && <DropdownMenuItem onSelect={() => onReply(selected)}><Reply className="h-4 w-4" />回复</DropdownMenuItem>}
|
{canSend && <DropdownMenuItem onSelect={() => onReply(selected)}><Reply className="h-4 w-4" />回复</DropdownMenuItem>}
|
||||||
{canSend && <DropdownMenuItem onSelect={() => onForward(selected)}><Forward className="h-4 w-4" />转发</DropdownMenuItem>}
|
{canSend && <DropdownMenuItem onSelect={() => onForward(selected)}><Forward className="h-4 w-4" />转发</DropdownMenuItem>}
|
||||||
|
{selected.sendQueueId && <DropdownMenuItem onSelect={() => onSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</DropdownMenuItem>}
|
||||||
{canOrganize && <DropdownMenuItem onSelect={() => onArchive(selected)}><Archive className="h-4 w-4" />{selected.folder === "Archive" ? "取消归档" : "归档"}</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onArchive(selected)}><Archive className="h-4 w-4" />{selected.folder === "Archive" ? "取消归档" : "归档"}</DropdownMenuItem>}
|
||||||
{canOrganize && <DropdownMenuItem onSelect={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</DropdownMenuItem>}
|
||||||
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
{canOrganize && <DropdownMenuItem onSelect={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</DropdownMenuItem>}
|
||||||
@@ -2204,6 +2262,7 @@ function CompactMessageDetail({
|
|||||||
<>
|
<>
|
||||||
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onReply(selected)}><Reply className="h-4 w-4" />回复</Button>}
|
||||||
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
{selected && canSend && <Button variant="outline" size="sm" onClick={() => onForward(selected)}><Forward className="h-4 w-4" />转发</Button>}
|
||||||
|
{selected?.sendQueueId && <Button variant="outline" size="sm" onClick={() => onSendTimeline(selected)}><History className="h-4 w-4" />投递时间线</Button>}
|
||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}</Button>}
|
||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onToggleRead(selected)}><MailCheck className="h-4 w-4" />{selected.isRead ? "标为未读" : "标为已读"}</Button>}
|
||||||
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
{selected && canOrganize && <Button variant="outline" size="sm" onClick={() => onStar(selected)}><Star className={cn("h-4 w-4", selected.isStarred && "fill-yellow-400 text-yellow-500")} />{selected.isStarred ? "取消星标" : "添加星标"}</Button>}
|
||||||
|
|||||||
Reference in New Issue
Block a user