feat(mail): 增强发送队列筛选与投递追踪能力

- 发送队列支持按 Message-ID、收件人和时间范围筛选,并改为稳定游标分页。
- 邮件详情补充关联的发送队列信息,支持从邮件直接查看投递时间线。
- 前端同步接入新筛选条件,并在发送队列页提供清除筛选入口。
This commit is contained in:
LanQin_
2026-06-25 16:13:44 +08:00
parent 4ab7815886
commit 98e7190512
6 changed files with 347 additions and 58 deletions
+132
View File
@@ -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: "<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) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
+97 -7
View File
@@ -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
}
+31 -29
View File
@@ -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 {