feat(mail): 增加发送队列管理

- 新增发送队列与投递时间线接口,支持查看、筛选、重试和取消发送任务。
- 扩展邮箱页面,加入发送队列入口、状态筛选和时间线弹窗。
- 更新发送队列状态流转,支持 `canceled` 并补充相关测试与前端类型定义。
This commit is contained in:
LanQin_
2026-06-24 16:39:40 +08:00
parent b3669f189e
commit 2b846ac671
8 changed files with 785 additions and 17 deletions
+224
View File
@@ -1054,6 +1054,193 @@ func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
}
}
func TestSendQueueAPIPermissionIsolation(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()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
aliceMB := createTestMailbox(t, admin, domainID, "queue-alice", "Queue Alice", "Password123!", nil)
bobMB := createTestMailbox(t, admin, domainID, "queue-bob", "Queue Bob", "Password123!", nil)
aliceUser, _, err := a.userByEmail(context.Background(), aliceMB.Address)
if err != nil {
t.Fatal(err)
}
bobUser, _, err := a.userByEmail(context.Background(), bobMB.Address)
if err != nil {
t.Fatal(err)
}
now := a.now().UTC()
aliceQueueID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: aliceUser.ID,
MailboxID: aliceMB.ID,
MessageID: "<alice-queue@example.test>",
Source: sendSourceWebmail,
MailFrom: aliceMB.Address,
HeaderFrom: aliceMB.Address,
Recipients: []string{"person@example.test"},
MIMEBytes: []byte("From: queue-alice@example.test\r\nTo: person@example.test\r\nSubject: alice\r\n\r\nbody"),
Now: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: bobUser.ID,
MailboxID: bobMB.ID,
MessageID: "<bob-queue@example.test>",
Source: sendSourceWebmail,
MailFrom: bobMB.Address,
HeaderFrom: bobMB.Address,
Recipients: []string{"person@example.test"},
MIMEBytes: []byte("From: queue-bob@example.test\r\nTo: person@example.test\r\nSubject: bob\r\n\r\nbody"),
Now: now,
}); err != nil {
t.Fatal(err)
}
alice := &testClient{t: t, server: ts}
if code := alice.do("POST", "/api/auth/login", map[string]string{"email": aliceMB.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("alice login code=%d", code)
}
var list struct {
Items []SendQueueEntry `json:"items"`
}
if code := alice.do("GET", "/api/mail/send-queue?mailboxId="+aliceMB.ID, nil, &list); code != http.StatusOK {
t.Fatalf("list own queue code=%d items=%+v", code, list.Items)
}
if len(list.Items) != 1 || list.Items[0].ID != aliceQueueID || list.Items[0].MailboxID != aliceMB.ID {
t.Fatalf("own queue isolation failed: %+v", list.Items)
}
if code := alice.do("GET", "/api/mail/send-queue?mailboxId="+bobMB.ID, nil, &map[string]any{}); code != http.StatusNotFound {
t.Fatalf("listing another mailbox should be hidden, code=%d", code)
}
if code := alice.do("GET", "/api/mail/send-queue/"+list.Items[0].ID+"/audit", nil, &struct {
Items []SendAuditEvent `json:"items"`
}{}); code != http.StatusOK {
t.Fatalf("own audit code=%d", code)
}
}
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
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()
failedID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: "<failed-retry-api@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"person@example.test"},
MIMEBytes: []byte("From: admin@lanqin.local\r\nTo: person@example.test\r\nSubject: retry\r\n\r\nbody"),
Now: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,attempt_count=3,last_error='temporary failure',next_attempt_at=? WHERE id=?`, sendQueueStatusFailed, now.Add(time.Hour).Format(time.RFC3339Nano), failedID); err != nil {
t.Fatal(err)
}
var retried SendQueueEntry
if code := client.do("POST", "/api/mail/send-queue/"+failedID+"/retry", nil, &retried); code != http.StatusOK {
t.Fatalf("retry failed queue code=%d item=%+v", code, retried)
}
if retried.Status != sendQueueStatusQueued || retried.AttemptCount != 0 || retried.LastError != "" {
t.Fatalf("retry did not reset queue item: %+v", retried)
}
if err := a.processDueSendQueue(context.Background()); err != nil {
t.Fatal(err)
}
select {
case body := <-received:
if !strings.Contains(body, "Subject: retry") {
t.Fatalf("unexpected retried body: %q", body)
}
case <-time.After(2 * time.Second):
t.Fatal("retried queue item was not relayed")
}
deliveredID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: "<delivered-retry-api@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"person@example.test"},
MIMEBytes: []byte("From: admin@lanqin.local\r\nTo: person@example.test\r\nSubject: delivered\r\n\r\nbody"),
Now: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,delivered_at=?,mime_base64='' WHERE id=?`, sendQueueStatusDelivered, now.Format(time.RFC3339Nano), deliveredID); err != nil {
t.Fatal(err)
}
if code := client.do("POST", "/api/mail/send-queue/"+deliveredID+"/retry", nil, &map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("delivered retry should be rejected, code=%d", code)
}
cancelID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: "<cancel-api@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"person@example.test"},
MIMEBytes: []byte("From: admin@lanqin.local\r\nTo: person@example.test\r\nSubject: cancel\r\n\r\nbody"),
Now: now,
})
if err != nil {
t.Fatal(err)
}
var canceled SendQueueEntry
if code := client.do("DELETE", "/api/mail/send-queue/"+cancelID, nil, &canceled); code != http.StatusOK {
t.Fatalf("cancel queued item code=%d item=%+v", code, canceled)
}
if canceled.Status != sendQueueStatusCanceled {
t.Fatalf("canceled status=%q", canceled.Status)
}
if err := a.processDueSendQueue(context.Background()); err != nil {
t.Fatal(err)
}
select {
case body := <-received:
if strings.Contains(body, "Subject: cancel") {
t.Fatalf("canceled queue item was relayed: %q", body)
}
case <-time.After(200 * time.Millisecond):
}
var status string
if err := a.db.QueryRow(`SELECT status FROM send_queue WHERE id=?`, cancelID).Scan(&status); err != nil {
t.Fatal(err)
}
if status != sendQueueStatusCanceled {
t.Fatalf("cancel status after worker=%q", status)
}
}
func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
a := newTestApp(t)
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
@@ -1381,6 +1568,43 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
}
}
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
if err != nil {
t.Fatal(err)
}
raw := "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: canceled resend\r\nMessage-ID: <canceled-requeue@example.test>\r\n\r\nbody"
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,updated_at=? WHERE mailbox_id=? AND message_id=?`, sendQueueStatusCanceled, a.now().UTC().Format(time.RFC3339Nano), mb.ID, "<canceled-requeue@example.test>"); err != nil {
t.Fatal(err)
}
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
t.Fatal(err)
}
if err := a.processDueSendQueue(context.Background()); err != nil {
t.Fatal(err)
}
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("requeued canceled message was not relayed")
}
var status string
var attemptCount int
if err := a.db.QueryRow(`SELECT status,attempt_count FROM send_queue WHERE mailbox_id=? AND message_id=?`, mb.ID, "<canceled-requeue@example.test>").Scan(&status, &attemptCount); err != nil {
t.Fatal(err)
}
if status != sendQueueStatusDelivered || attemptCount != 1 {
t.Fatalf("queue status=%q attempts=%d, want delivered attempts=1", status, attemptCount)
}
}
func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()