From 98e71905121f4cce65e8f0df5072815b42899eb9 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Thu, 25 Jun 2026 16:13:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E5=A2=9E=E5=BC=BA=E5=8F=91?= =?UTF-8?q?=E9=80=81=E9=98=9F=E5=88=97=E7=AD=9B=E9=80=89=E4=B8=8E=E6=8A=95?= =?UTF-8?q?=E9=80=92=E8=BF=BD=E8=B8=AA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发送队列支持按 Message-ID、收件人和时间范围筛选,并改为稳定游标分页。 - 邮件详情补充关联的发送队列信息,支持从邮件直接查看投递时间线。 - 前端同步接入新筛选条件,并在发送队列页提供清除筛选入口。 --- apps/api/internal/app/app_test.go | 132 +++++++++++++++++++++++++ apps/api/internal/app/mail_handlers.go | 104 +++++++++++++++++-- apps/api/internal/app/types.go | 60 +++++------ apps/web/src/lib/api-types.ts | 2 + apps/web/src/lib/api.ts | 6 +- apps/web/src/pages/mail.tsx | 101 +++++++++++++++---- 6 files changed, 347 insertions(+), 58 deletions(-) diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 9f296c0..7287dd9 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -13,6 +13,7 @@ import ( "encoding/json" "encoding/pem" "errors" + "fmt" "io" "log/slog" "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: "", + 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: "", + 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: "", + 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: "", + 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("", 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(""), 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) { a := newTestApp(t) host, port, received := startCapturingSMTP(t, 1) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index e0afca3..8a76dcb 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -1164,9 +1164,10 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) { return } status := strings.TrimSpace(r.URL.Query().Get("status")) - cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor")) - if cursor < 0 { - cursor = 0 + cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor")) + if err != nil { + badRequest(w, err) + return } limit := 30 args := []any{user.ID, mb.ID} @@ -1179,9 +1180,44 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) { where += ` AND sq.status=?` args = append(args, status) } - args = append(args, limit+1, cursor) - 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 - 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...) + 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 @@ -1203,7 +1239,8 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) { next := "" if len(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}) } @@ -1349,12 +1386,62 @@ func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) 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: @@ -1915,6 +2002,9 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma 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 } diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 47270bb..70fb3fa 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -77,35 +77,37 @@ type MailLabel struct { } type MailMessage struct { - ID string `json:"id"` - MailboxID string `json:"mailboxId,omitempty"` - MailboxAddress string `json:"mailboxAddress,omitempty"` - OwnerEmail string `json:"ownerEmail,omitempty"` - RecipientAddr string `json:"recipientAddress,omitempty"` - FolderID string `json:"folderId"` - Folder string `json:"folder"` - MessageUID string `json:"messageUid"` - IMAPUID int64 `json:"imapUid"` - IMAPModSeq int64 `json:"imapModseq"` - MessageID string `json:"messageId"` - Subject string `json:"subject"` - From string `json:"from"` - FromName string `json:"fromName,omitempty"` - To []string `json:"to"` - CC []string `json:"cc"` - BCC []string `json:"bcc,omitempty"` - SentAt time.Time `json:"sentAt"` - ReceivedAt time.Time `json:"receivedAt"` - Snippet string `json:"snippet"` - BodyText string `json:"bodyText,omitempty"` - BodyHTML string `json:"bodyHtml,omitempty"` - IsRead bool `json:"isRead"` - IsStarred bool `json:"isStarred"` - HasAttachments bool `json:"hasAttachments"` - SizeBytes int64 `json:"sizeBytes"` - Labels []MailLabel `json:"labels,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - Authentication MailAuthentication `json:"authentication"` + ID string `json:"id"` + MailboxID string `json:"mailboxId,omitempty"` + MailboxAddress string `json:"mailboxAddress,omitempty"` + OwnerEmail string `json:"ownerEmail,omitempty"` + RecipientAddr string `json:"recipientAddress,omitempty"` + FolderID string `json:"folderId"` + Folder string `json:"folder"` + MessageUID string `json:"messageUid"` + IMAPUID int64 `json:"imapUid"` + IMAPModSeq int64 `json:"imapModseq"` + MessageID string `json:"messageId"` + Subject string `json:"subject"` + From string `json:"from"` + FromName string `json:"fromName,omitempty"` + To []string `json:"to"` + CC []string `json:"cc"` + BCC []string `json:"bcc,omitempty"` + SentAt time.Time `json:"sentAt"` + ReceivedAt time.Time `json:"receivedAt"` + Snippet string `json:"snippet"` + BodyText string `json:"bodyText,omitempty"` + BodyHTML string `json:"bodyHtml,omitempty"` + IsRead bool `json:"isRead"` + IsStarred bool `json:"isStarred"` + HasAttachments bool `json:"hasAttachments"` + SizeBytes int64 `json:"sizeBytes"` + Labels []MailLabel `json:"labels,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + Authentication MailAuthentication `json:"authentication"` + SendQueueID string `json:"sendQueueId,omitempty"` + SendQueueStatus string `json:"sendQueueStatus,omitempty"` } type MailAuthentication struct { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index f51a322..a6629fb 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -62,6 +62,8 @@ export type MailLabel = { id: string; mailboxId?: string; name: string; color: s 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[] labels?: MailLabel[] + sendQueueId?: string + sendQueueStatus?: SendQueueStatus } export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSCheckResult = { domain: string; status: string; checks: Record } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 789e525..556510c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -151,11 +151,15 @@ export const api = { scheduledSends: (mailboxId?: string) => request>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), scheduleSend: (payload: ScheduleSendPayload) => request("/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" }), - 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() if (params.mailboxId) query.set("mailboxId", params.mailboxId) if (params.status && params.status !== "all") query.set("status", params.status) 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() return request>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`) }, diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 1ec4db0..03a815c 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -108,6 +108,10 @@ export function MailPage() { const [pendingConfirm, setPendingConfirm] = React.useState(null) const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const [sendQueueStatus, setSendQueueStatus] = React.useState("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 [sendQueuePendingId, setSendQueuePendingId] = React.useState("") 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 scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 }) 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 mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false const inboxProbe = useQuery({ @@ -530,10 +539,7 @@ export function MailPage() { : scheduledItems const sendQueueItems = sendQueue.data?.items || [] const sendQueueCount = sendQueueItems.filter((item) => item.status === "failed" || item.status === "queued" || item.status === "sending").length - const sendQueueQuery = query.trim().toLowerCase() - const visibleSendQueueItems = sendQueueQuery - ? sendQueueItems.filter((item) => [item.subject, item.source, item.lastError, item.error, item.failureReason, ...(item.recipients || [])].join(" ").toLowerCase().includes(sendQueueQuery)) - : sendQueueItems + const visibleSendQueueItems = sendQueueItems const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail, canViewSendQueue ? sendQueueCount : 0, canViewSendQueue) const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) @@ -837,6 +843,10 @@ export function MailPage() { setMailFilter("all") setMobileSidebarOpen(false) } + function openMessageSendTimeline(message: MailMessage) { + if (!message.sendQueueId) return + setSendQueueAuditId(message.sendQueueId) + } function openLabel(labelId: string) { setSelectedLabelId(labelId) setMailView("label") @@ -1077,10 +1087,18 @@ export function MailPage() { items={visibleSendQueueItems} total={sendQueueItems.length} loading={sendQueue.isLoading} - query={query} status={sendQueueStatus} + messageId={sendQueueMessageId} + recipient={sendQueueRecipient} + from={sendQueueFrom} + to={sendQueueTo} pendingId={sendQueuePendingId} onStatusChange={setSendQueueStatus} + onMessageIdChange={setSendQueueMessageId} + onRecipientChange={setSendQueueRecipient} + onFromChange={setSendQueueFrom} + onToChange={setSendQueueTo} + onClearFilters={() => { setSendQueueMessageId(""); setSendQueueRecipient(""); setSendQueueFrom(""); setSendQueueTo(""); setSendQueueStatus("all") }} onRetry={(item) => retrySendQueue.mutate(item)} onCancel={(item) => cancelSendQueue.mutate(item)} onAudit={(item) => setSendQueueAuditId(item.id)} @@ -1115,6 +1133,7 @@ export function MailPage() { onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })} onReply={openReply} onForward={openForward} + onSendTimeline={openMessageSendTimeline} onArchive={(message) => move.mutate({ id: message.id, folder: message.folder === "Archive" ? "Inbox" : "Archive" })} onDelete={confirmDeleteMessage} onToggleRead={(message) => markRead.mutate({ id: message.id, read: !message.isRead })} @@ -1169,6 +1188,7 @@ export function MailPage() {
{canSendMail && } {canSendMail && } + {selected.sendQueueId && } {canOrganizeMail && (selected.folder === "Archive" ? ( ) : ( @@ -1563,10 +1583,18 @@ function SendQueueView({ items, total, loading, - query, status, + messageId, + recipient, + from, + to, pendingId, onStatusChange, + onMessageIdChange, + onRecipientChange, + onFromChange, + onToChange, + onClearFilters, onRetry, onCancel, onAudit, @@ -1576,31 +1604,49 @@ function SendQueueView({ items: SendQueueItem[] total: number loading: boolean - query: string status: SendQueueStatus | "all" + messageId: string + recipient: string + from: string + to: string pendingId: string 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 onCancel: (item: SendQueueItem) => void onAudit: (item: SendQueueItem) => void canMutate: boolean }) { - const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空" + const hasFilters = status !== "all" || messageId.trim() || recipient.trim() || from || to + const empty = hasFilters ? "当前筛选没有匹配的发送任务" : "发送队列为空" return (
-
-
-
发送队列
-
{items.length} / {total} 个发送任务
+
+
+
+
发送队列
+
{items.length} / {total} 个发送任务
+
+ +
+
+ onMessageIdChange(event.target.value)} placeholder="Message-ID" className="h-9" /> + onRecipientChange(event.target.value)} placeholder="收件人" className="h-9" /> + onFromChange(event.target.value)} className="h-9" /> + onToChange(event.target.value)} className="h-9" /> +
-
{loading && } @@ -1723,6 +1769,12 @@ function sendQueueSourceLabel(source: string) { 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" function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { @@ -1995,6 +2047,7 @@ function CompactMailView({ onStar, onReply, onForward, + onSendTimeline, onArchive, onDelete, onToggleRead, @@ -2033,6 +2086,7 @@ function CompactMailView({ onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void + onSendTimeline: (message: MailMessage) => void onArchive: (message: MailMessage) => void onDelete: (message: MailMessage) => void onToggleRead: (message: MailMessage) => void @@ -2064,6 +2118,7 @@ function CompactMailView({ onStar={onStar} onReply={onReply} onForward={onForward} + onSendTimeline={onSendTimeline} onArchive={onArchive} onDelete={onDelete} onToggleRead={onToggleRead} @@ -2126,6 +2181,7 @@ function CompactMessageDetail({ onStar, onReply, onForward, + onSendTimeline, onArchive, onDelete, onToggleRead, @@ -2147,6 +2203,7 @@ function CompactMessageDetail({ onStar: (message: MailMessage) => void onReply: (message: MailMessage) => void onForward: (message: MailMessage) => void + onSendTimeline: (message: MailMessage) => void onArchive: (message: MailMessage) => void onDelete: (message: MailMessage) => void onToggleRead: (message: MailMessage) => void @@ -2185,6 +2242,7 @@ function CompactMessageDetail({ <> {canSend && onReply(selected)}>回复} {canSend && onForward(selected)}>转发} + {selected.sendQueueId && onSendTimeline(selected)}>投递时间线} {canOrganize && onArchive(selected)}>{selected.folder === "Archive" ? "取消归档" : "归档"}} {canOrganize && onToggleRead(selected)}>{selected.isRead ? "标为未读" : "标为已读"}} {canOrganize && onStar(selected)}>{selected.isStarred ? "取消星标" : "添加星标"}} @@ -2204,6 +2262,7 @@ function CompactMessageDetail({ <> {selected && canSend && } {selected && canSend && } + {selected?.sendQueueId && } {selected && canOrganize && } {selected && canOrganize && } {selected && canOrganize && }