feat(mail): 增加发送队列管理
- 新增发送队列与投递时间线接口,支持查看、筛选、重试和取消发送任务。 - 扩展邮箱页面,加入发送队列入口、状态筛选和时间线弹窗。 - 更新发送队列状态流转,支持 `canceled` 并补充相关测试与前端类型定义。
This commit is contained in:
@@ -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) {
|
func TestSubmissionAuthRequiresMailboxPasswordAndSendPermission(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
|
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) {
|
func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -907,6 +907,214 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
|
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||||
|
if cursor < 0 {
|
||||||
|
cursor = 0
|
||||||
|
}
|
||||||
|
limit := 30
|
||||||
|
args := []any{user.ID, mb.ID}
|
||||||
|
where := `mb.user_id=? AND sq.mailbox_id=?`
|
||||||
|
if status != "" {
|
||||||
|
if !validSendQueueStatus(status) {
|
||||||
|
badRequest(w, errors.New("invalid send queue status"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SendQueueEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanSendQueueEntry(rows)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan send queue")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(items) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
next = strconv.Itoa(cursor + limit)
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleSendQueueAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||||
|
if !a.sendQueueBelongsToUser(r.Context(), id, user.ID) {
|
||||||
|
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at
|
||||||
|
FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC`, id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SendAuditEvent{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item SendAuditEvent
|
||||||
|
var recipientsJSON, createdAt string
|
||||||
|
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||||
|
item.CreatedAt = parseTime(createdAt)
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleRetrySendQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||||
|
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item.Status != sendQueueStatusFailed {
|
||||||
|
badRequest(w, errors.New("send queue item is not failed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
|
||||||
|
sendQueueStatusQueued, now, now, id, sendQueueStatusFailed, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to retry send queue item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.deleteSendQueueDeliveredMarker(id)
|
||||||
|
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInput{
|
||||||
|
QueueID: item.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: item.MailboxID,
|
||||||
|
SentMessageID: item.SentMessageID,
|
||||||
|
Source: item.Source,
|
||||||
|
MailFrom: item.MailFrom,
|
||||||
|
HeaderFrom: item.HeaderFrom,
|
||||||
|
Recipients: item.Recipients,
|
||||||
|
})
|
||||||
|
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCancelSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||||
|
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
|
||||||
|
badRequest(w, errors.New("send queue item cannot be canceled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`,
|
||||||
|
sendQueueStatusCanceled, now, id, sendQueueStatusQueued, sendQueueStatusFailed, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to cancel send queue item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respondError(w, http.StatusNotFound, "send queue item not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.deleteSendQueueDeliveredMarker(id)
|
||||||
|
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInput{
|
||||||
|
QueueID: item.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
MailboxID: item.MailboxID,
|
||||||
|
SentMessageID: item.SentMessageID,
|
||||||
|
Source: item.Source,
|
||||||
|
MailFrom: item.MailFrom,
|
||||||
|
HeaderFrom: item.HeaderFrom,
|
||||||
|
Recipients: item.Recipients,
|
||||||
|
})
|
||||||
|
updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load send queue item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sendQueueEntryScanner interface{ Scan(dest ...any) error }
|
||||||
|
|
||||||
|
func scanSendQueueEntry(row sendQueueEntryScanner) (SendQueueEntry, error) {
|
||||||
|
var item SendQueueEntry
|
||||||
|
var recipientsJSON, nextAttemptAt, createdAt, updatedAt string
|
||||||
|
var deliveredAt sql.NullString
|
||||||
|
err := row.Scan(&item.ID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Subject, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Status, &item.AttemptCount, &item.MaxAttempts, &nextAttemptAt, &item.LastError, &createdAt, &updatedAt, &deliveredAt)
|
||||||
|
if err != nil {
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||||
|
item.NextAttemptAt = parseTime(nextAttemptAt)
|
||||||
|
item.CreatedAt = parseTime(createdAt)
|
||||||
|
item.UpdatedAt = parseTime(updatedAt)
|
||||||
|
item.DeliveredAt = nullableTime(deliveredAt)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) (SendQueueEntry, error) {
|
||||||
|
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||||
|
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?`, id, userID)
|
||||||
|
return scanSendQueueEntry(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 validSendQueueStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
MailboxID string `json:"mailboxId"`
|
MailboxID string `json:"mailboxId"`
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ func (a *App) Router() http.Handler {
|
|||||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||||
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||||
|
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||||
|
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
|
||||||
|
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
|
||||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ const (
|
|||||||
sendQueueStatusSending = "sending"
|
sendQueueStatusSending = "sending"
|
||||||
sendQueueStatusDelivered = "delivered"
|
sendQueueStatusDelivered = "delivered"
|
||||||
sendQueueStatusFailed = "failed"
|
sendQueueStatusFailed = "failed"
|
||||||
|
sendQueueStatusCanceled = "canceled"
|
||||||
|
|
||||||
sendAuditAccepted = "accepted"
|
sendAuditAccepted = "accepted"
|
||||||
sendAuditQueued = "queued"
|
sendAuditQueued = "queued"
|
||||||
sendAuditDelivered = "delivered"
|
sendAuditDelivered = "delivered"
|
||||||
sendAuditFailed = "failed"
|
sendAuditFailed = "failed"
|
||||||
sendAuditRetry = "retry"
|
sendAuditRetry = "retry"
|
||||||
|
sendAuditCanceled = "canceled"
|
||||||
|
|
||||||
sendSourceWebmail = "webmail"
|
sendSourceWebmail = "webmail"
|
||||||
sendSourceSubmission = "submission"
|
sendSourceSubmission = "submission"
|
||||||
@@ -85,7 +87,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if existingID != id {
|
if existingID != id {
|
||||||
if status == sendQueueStatusDelivered || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
||||||
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -209,3 +209,38 @@ type MailStatsFolderCount struct {
|
|||||||
Unread int64 `json:"unread"`
|
Unread int64 `json:"unread"`
|
||||||
Bytes int64 `json:"bytes"`
|
Bytes int64 `json:"bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SendQueueEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MailboxID string `json:"mailboxId"`
|
||||||
|
SentMessageID string `json:"sentMessageId"`
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
MailFrom string `json:"mailFrom"`
|
||||||
|
HeaderFrom string `json:"headerFrom"`
|
||||||
|
Recipients []string `json:"recipients"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AttemptCount int `json:"attemptCount"`
|
||||||
|
MaxAttempts int `json:"maxAttempts"`
|
||||||
|
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendAuditEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
QueueID string `json:"queueId"`
|
||||||
|
MailboxID string `json:"mailboxId"`
|
||||||
|
SentMessageID string `json:"sentMessageId"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MailFrom string `json:"mailFrom"`
|
||||||
|
HeaderFrom string `json:"headerFrom"`
|
||||||
|
Recipients []string `json:"recipients"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,45 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
|
|||||||
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
||||||
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
|
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
|
||||||
|
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
|
||||||
|
export type SendQueueItem = {
|
||||||
|
id: string
|
||||||
|
mailboxId: string
|
||||||
|
sentMessageId?: string
|
||||||
|
messageId?: string
|
||||||
|
mailFrom?: string
|
||||||
|
headerFrom?: string
|
||||||
|
subject: string
|
||||||
|
recipients: string[]
|
||||||
|
source: string
|
||||||
|
status: SendQueueStatus
|
||||||
|
attemptCount: number
|
||||||
|
maxAttempts: number
|
||||||
|
nextAttemptAt?: string
|
||||||
|
lastError?: string
|
||||||
|
error?: string
|
||||||
|
failureReason?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
deliveredAt?: string
|
||||||
|
}
|
||||||
|
export type SendQueueAuditEvent = {
|
||||||
|
id: string
|
||||||
|
queueId?: string
|
||||||
|
mailboxId?: string
|
||||||
|
sentMessageId?: string
|
||||||
|
source?: string
|
||||||
|
status?: SendQueueStatus
|
||||||
|
event?: string
|
||||||
|
eventType?: string
|
||||||
|
mailFrom?: string
|
||||||
|
headerFrom?: string
|
||||||
|
recipients?: string[]
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
attemptCount?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
||||||
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
||||||
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||||
export * from "./api-types"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -131,6 +131,17 @@ 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 } = {}) => {
|
||||||
|
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)
|
||||||
|
const suffix = query.toString()
|
||||||
|
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
||||||
|
},
|
||||||
|
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
|
||||||
|
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
|
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
|
||||||
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
|
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
|
||||||
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||||
|
|||||||
+260
-15
@@ -12,8 +12,8 @@ import Placeholder from "@tiptap/extension-placeholder"
|
|||||||
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, History, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, RotateCcw, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api"
|
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api"
|
||||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { useDisplayMode } from "@/lib/display-mode"
|
import { useDisplayMode } from "@/lib/display-mode"
|
||||||
@@ -61,7 +61,7 @@ const folderLabels: Record<string, string> = {
|
|||||||
|
|
||||||
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
|
type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean }
|
||||||
type MailFilter = "all" | "unread" | "starred" | "attachments"
|
type MailFilter = "all" | "unread" | "starred" | "attachments"
|
||||||
type MailView = "folder" | "starred" | "label" | "scheduled"
|
type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue"
|
||||||
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
|
||||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||||
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
type MailNotificationState = { latestId: string; latestReceivedAt: string }
|
||||||
@@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri
|
|||||||
type MailMenuItem =
|
type MailMenuItem =
|
||||||
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||||
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||||
|
| { type: "sendQueue"; key: string; label: string; icon: React.ReactNode; count: number }
|
||||||
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
| { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number }
|
||||||
|
|
||||||
const filterLabels: Record<MailFilter, string> = {
|
const filterLabels: Record<MailFilter, string> = {
|
||||||
@@ -103,6 +104,9 @@ export function MailPage() {
|
|||||||
const [bulkPending, setBulkPending] = React.useState(false)
|
const [bulkPending, setBulkPending] = React.useState(false)
|
||||||
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 [sendQueueAuditId, setSendQueueAuditId] = React.useState("")
|
||||||
|
const [sendQueuePendingId, setSendQueuePendingId] = React.useState("")
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||||
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
const [labelEditMode, setLabelEditMode] = React.useState(false)
|
||||||
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
const [newLabelEditing, setNewLabelEditing] = React.useState(false)
|
||||||
@@ -130,6 +134,9 @@ export function MailPage() {
|
|||||||
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && (canReadMail || canManageLabels) })
|
||||||
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 sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), 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 mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false
|
||||||
const inboxProbe = useQuery({
|
const inboxProbe = useQuery({
|
||||||
queryKey: ["mail-notifications", activeMailboxId],
|
queryKey: ["mail-notifications", activeMailboxId],
|
||||||
@@ -148,7 +155,7 @@ export function MailPage() {
|
|||||||
},
|
},
|
||||||
initialPageParam: "",
|
initialPageParam: "",
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
|
||||||
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId),
|
enabled: !!activeMailboxId && canReadMail && mailView !== "scheduled" && mailView !== "sendQueue" && (mailView !== "label" || !!selectedLabelId),
|
||||||
})
|
})
|
||||||
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId && canReadMail })
|
||||||
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
function updateCachedMessage(id: string, patch: Partial<MailMessage>) {
|
||||||
@@ -276,6 +283,28 @@ export function MailPage() {
|
|||||||
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
onSettled: () => setCancelingScheduledId(""),
|
onSettled: () => setCancelingScheduledId(""),
|
||||||
})
|
})
|
||||||
|
const retrySendQueue = useMutation({
|
||||||
|
mutationFn: (item: SendQueueItem) => api.retrySendQueue(item.id),
|
||||||
|
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||||
|
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||||
|
toast({ title: "已重新加入发送队列" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "重试失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
onSettled: () => setSendQueuePendingId(""),
|
||||||
|
})
|
||||||
|
const cancelSendQueue = useMutation({
|
||||||
|
mutationFn: (item: SendQueueItem) => api.cancelSendQueue(item.id),
|
||||||
|
onMutate: (item) => setSendQueuePendingId(item.id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["send-queue"] })
|
||||||
|
await qc.invalidateQueries({ queryKey: ["send-queue-audit"] })
|
||||||
|
toast({ title: "已取消发送任务" })
|
||||||
|
},
|
||||||
|
onError: (error) => toast({ title: "取消失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
|
onSettled: () => setSendQueuePendingId(""),
|
||||||
|
})
|
||||||
const markAllRead = useMutation({
|
const markAllRead = useMutation({
|
||||||
mutationFn: async (items: MailMessage[]) => {
|
mutationFn: async (items: MailMessage[]) => {
|
||||||
const unread = items.filter((message) => !message.isRead)
|
const unread = items.filter((message) => !message.isRead)
|
||||||
@@ -403,6 +432,7 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
qc.invalidateQueries({ queryKey: ["mail-notifications"] }),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
setLastAutoRefreshAt(new Date())
|
setLastAutoRefreshAt(new Date())
|
||||||
@@ -429,10 +459,16 @@ export function MailPage() {
|
|||||||
const visibleScheduledItems = scheduledQuery
|
const visibleScheduledItems = scheduledQuery
|
||||||
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery))
|
||||||
: scheduledItems
|
: scheduledItems
|
||||||
const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, canScheduleMail ? scheduledCount : 0, canScheduleMail)
|
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 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)
|
||||||
const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
const viewTitle = mailView === "sendQueue" ? "发送队列" : mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder
|
||||||
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length)
|
||||||
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
const visibleMessageIds = visibleMessages.map((message) => message.id)
|
||||||
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length
|
||||||
@@ -453,6 +489,7 @@ export function MailPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
qc.invalidateQueries({ queryKey: ["mail-stats"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["labels"] }),
|
qc.invalidateQueries({ queryKey: ["labels"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
qc.invalidateQueries({ queryKey: ["scheduled-sends"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["send-queue"] }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
async function runBulkAction(action: BulkAction) {
|
async function runBulkAction(action: BulkAction) {
|
||||||
@@ -576,6 +613,13 @@ export function MailPage() {
|
|||||||
setMailFilter("all")
|
setMailFilter("all")
|
||||||
setMobileSidebarOpen(false)
|
setMobileSidebarOpen(false)
|
||||||
}
|
}
|
||||||
|
function openSendQueue() {
|
||||||
|
setMailView("sendQueue")
|
||||||
|
setSelectedLabelId("")
|
||||||
|
setSelectedId(null)
|
||||||
|
setMailFilter("all")
|
||||||
|
setMobileSidebarOpen(false)
|
||||||
|
}
|
||||||
function openLabel(labelId: string) {
|
function openLabel(labelId: string) {
|
||||||
setSelectedLabelId(labelId)
|
setSelectedLabelId(labelId)
|
||||||
setMailView("label")
|
setMailView("label")
|
||||||
@@ -654,9 +698,9 @@ export function MailPage() {
|
|||||||
{mailMenuItems.map((item) => (
|
{mailMenuItems.map((item) => (
|
||||||
<SidebarMenuItem key={item.key}>
|
<SidebarMenuItem key={item.key}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : mailView === "folder" && folder === item.folderName}
|
isActive={item.type === "starred" ? mailView === "starred" : item.type === "scheduled" ? mailView === "scheduled" : item.type === "sendQueue" ? mailView === "sendQueue" : mailView === "folder" && folder === item.folderName}
|
||||||
className={cn(sidebarCollapsed && "justify-center px-0")}
|
className={cn(sidebarCollapsed && "justify-center px-0")}
|
||||||
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)}
|
onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : item.type === "sendQueue" ? openSendQueue() : openFolder(item.folderName)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||||
@@ -772,6 +816,23 @@ export function MailPage() {
|
|||||||
/>
|
/>
|
||||||
) : mailView === "scheduled" ? (
|
) : mailView === "scheduled" ? (
|
||||||
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
<PermissionEmptyState title="无定时发送权限" description="当前账号不能查看或管理定时发送任务。" onOpenSettings={openSettings} />
|
||||||
|
) : mailView === "sendQueue" && canViewSendQueue ? (
|
||||||
|
<SendQueueView
|
||||||
|
compact={isMobile || displayMode === "compact"}
|
||||||
|
items={visibleSendQueueItems}
|
||||||
|
total={sendQueueItems.length}
|
||||||
|
loading={sendQueue.isLoading}
|
||||||
|
query={query}
|
||||||
|
status={sendQueueStatus}
|
||||||
|
pendingId={sendQueuePendingId}
|
||||||
|
onStatusChange={setSendQueueStatus}
|
||||||
|
onRetry={(item) => retrySendQueue.mutate(item)}
|
||||||
|
onCancel={(item) => cancelSendQueue.mutate(item)}
|
||||||
|
onAudit={(item) => setSendQueueAuditId(item.id)}
|
||||||
|
canMutate={canSendMail}
|
||||||
|
/>
|
||||||
|
) : mailView === "sendQueue" ? (
|
||||||
|
<PermissionEmptyState title="无发送队列权限" description="当前账号不能查看发送队列。" onOpenSettings={openSettings} />
|
||||||
) : isMobile || displayMode === "compact" ? (
|
) : isMobile || displayMode === "compact" ? (
|
||||||
<CompactMailView
|
<CompactMailView
|
||||||
title={viewTitle}
|
title={viewTitle}
|
||||||
@@ -900,7 +961,7 @@ export function MailPage() {
|
|||||||
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
{canSendMail && <Button type="button" size="icon" onClick={() => openCompose()} disabled={!selectedMailbox} aria-label="写邮件"><PencilLine className="h-4 w-4" /></Button>}
|
||||||
<div className="relative basis-full">
|
<div className="relative basis-full">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
@@ -924,7 +985,7 @@ export function MailPage() {
|
|||||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mailView !== "scheduled" && (
|
{mailView !== "scheduled" && mailView !== "sendQueue" && (
|
||||||
<>
|
<>
|
||||||
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
{canOrganizeMail && <Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -944,7 +1005,7 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full max-w-md">
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{contentView}
|
{contentView}
|
||||||
@@ -954,7 +1015,13 @@ export function MailPage() {
|
|||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} />
|
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }); qc.invalidateQueries({ queryKey: ["send-queue"] }) }} />
|
||||||
|
<SendQueueAuditDialog
|
||||||
|
open={!!sendQueueAuditId}
|
||||||
|
loading={sendQueueAudit.isLoading}
|
||||||
|
events={sendQueueAudit.data?.items || []}
|
||||||
|
onOpenChange={(open) => { if (!open) setSendQueueAuditId("") }}
|
||||||
|
/>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingConfirm}
|
open={!!pendingConfirm}
|
||||||
title={pendingConfirm?.title || ""}
|
title={pendingConfirm?.title || ""}
|
||||||
@@ -969,7 +1036,7 @@ export function MailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean): MailMenuItem[] {
|
function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number, includeScheduled: boolean, sendQueueCount: number, includeSendQueue: boolean): MailMenuItem[] {
|
||||||
const byName = new Map(folders.map((item) => [item.name, item]))
|
const byName = new Map(folders.map((item) => [item.name, item]))
|
||||||
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
|
const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 })
|
||||||
for (const item of folders) {
|
for (const item of folders) {
|
||||||
@@ -985,10 +1052,13 @@ function buildMailMenuItems(folders: MailFolder[], starredCount: number, schedul
|
|||||||
}))
|
}))
|
||||||
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
|
const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: <Star className="h-4 w-4" />, count: starredCount }
|
||||||
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: <Clock3 className="h-4 w-4" />, count: scheduledCount }
|
||||||
|
const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: <History className="h-4 w-4" />, count: sendQueueCount }
|
||||||
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox")
|
||||||
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0
|
||||||
if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)]
|
const specialItems: MailMenuItem[] = [starredItem]
|
||||||
return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)]
|
if (includeScheduled) specialItems.push(scheduledItem)
|
||||||
|
if (includeSendQueue) specialItems.push(sendQueueItem)
|
||||||
|
return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
function FolderSkeleton() { return <div className="space-y-2 p-2"><Skeleton className="h-8 w-full" /><Skeleton className="h-8 w-4/5" /><Skeleton className="h-8 w-3/4" /></div> }
|
||||||
@@ -996,6 +1066,7 @@ function MessageSkeleton() { return <div className="space-y-0">{Array.from({ len
|
|||||||
|
|
||||||
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
function getEmptyMessage(mailView: MailView, folder: string, total: number) {
|
||||||
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件"
|
||||||
|
if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务"
|
||||||
if (total > 0) return "当前筛选条件下没有邮件"
|
if (total > 0) return "当前筛选条件下没有邮件"
|
||||||
if (mailView === "starred") return "暂无星标邮件"
|
if (mailView === "starred") return "暂无星标邮件"
|
||||||
if (mailView === "label") return "当前标签没有邮件"
|
if (mailView === "label") return "当前标签没有邮件"
|
||||||
@@ -1114,6 +1185,180 @@ function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendQueueStatusOptions: { value: SendQueueStatus | "all"; label: string }[] = [
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
{ value: "queued", label: "排队中" },
|
||||||
|
{ value: "sending", label: "发送中" },
|
||||||
|
{ value: "failed", label: "发送失败" },
|
||||||
|
{ value: "delivered", label: "已投递" },
|
||||||
|
{ value: "canceled", label: "已取消" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SendQueueView({
|
||||||
|
compact,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
query,
|
||||||
|
status,
|
||||||
|
pendingId,
|
||||||
|
onStatusChange,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
onAudit,
|
||||||
|
canMutate,
|
||||||
|
}: {
|
||||||
|
compact: boolean
|
||||||
|
items: SendQueueItem[]
|
||||||
|
total: number
|
||||||
|
loading: boolean
|
||||||
|
query: string
|
||||||
|
status: SendQueueStatus | "all"
|
||||||
|
pendingId: string
|
||||||
|
onStatusChange: (status: SendQueueStatus | "all") => void
|
||||||
|
onRetry: (item: SendQueueItem) => void
|
||||||
|
onCancel: (item: SendQueueItem) => void
|
||||||
|
onAudit: (item: SendQueueItem) => void
|
||||||
|
canMutate: boolean
|
||||||
|
}) {
|
||||||
|
const empty = query.trim() ? "当前搜索没有匹配的发送任务" : "发送队列为空"
|
||||||
|
return (
|
||||||
|
<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="min-w-0">
|
||||||
|
<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>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{loading && <ScheduledSendSkeleton />}
|
||||||
|
{!loading && items.length === 0 && <div className="p-8 text-center text-sm text-muted-foreground">{empty}</div>}
|
||||||
|
{!loading && items.map((item) => (
|
||||||
|
<SendQueueRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
compact={compact}
|
||||||
|
pending={pendingId === item.id}
|
||||||
|
onRetry={() => onRetry(item)}
|
||||||
|
onCancel={() => onCancel(item)}
|
||||||
|
onAudit={() => onAudit(item)}
|
||||||
|
canMutate={canMutate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendQueueRow({ item, compact, pending, onRetry, onCancel, onAudit, canMutate }: { item: SendQueueItem; compact: boolean; pending: boolean; onRetry: () => void; onCancel: () => void; onAudit: () => void; canMutate: boolean }) {
|
||||||
|
const recipients = item.recipients?.length ? item.recipients.join(", ") : "未记录收件人"
|
||||||
|
const failure = item.lastError || item.error || item.failureReason || ""
|
||||||
|
const canRetry = item.status === "failed"
|
||||||
|
const canCancel = item.status === "queued" || item.status === "failed"
|
||||||
|
return (
|
||||||
|
<div className={cn("border-b transition-colors hover:bg-accent/40", compact ? "p-4" : "px-5 py-4")}>
|
||||||
|
<div className={cn("gap-4", compact ? "space-y-3" : "grid grid-cols-[minmax(0,1fr)_210px_220px] items-center")}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1 flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold">{item.subject || "(无主题)"}</span>
|
||||||
|
<SendQueueStatusBadge status={item.status} />
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">发给 {recipients}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>来源:{sendQueueSourceLabel(item.source)}</span>
|
||||||
|
<span>尝试:{item.attemptCount}/{item.maxAttempts}</span>
|
||||||
|
{item.nextAttemptAt && <span>下次:{formatDateTime(item.nextAttemptAt)}</span>}
|
||||||
|
</div>
|
||||||
|
{failure && <div className="mt-2 line-clamp-2 text-xs text-destructive">{failure}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="text-xs text-muted-foreground">更新时间</div>
|
||||||
|
<div className="font-medium">{formatDateTime(item.updatedAt || item.createdAt)}</div>
|
||||||
|
{item.deliveredAt && <div className="text-xs text-muted-foreground">投递于 {formatDateTime(item.deliveredAt)}</div>}
|
||||||
|
</div>
|
||||||
|
<div className={cn("flex flex-wrap gap-2", compact ? "justify-start" : "justify-end")}>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onAudit}>
|
||||||
|
<History className="h-4 w-4" />时间线
|
||||||
|
</Button>
|
||||||
|
{canMutate && canRetry && (
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={onRetry}>
|
||||||
|
<RotateCcw className="h-4 w-4" />{pending ? "处理中..." : "重试"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canMutate && canCancel && (
|
||||||
|
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={onCancel}>
|
||||||
|
{pending ? "处理中..." : "取消"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) {
|
||||||
|
const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消"
|
||||||
|
return (
|
||||||
|
<Badge variant={status === "failed" ? "destructive" : status === "sending" || status === "queued" ? "secondary" : "outline"} className="h-5 shrink-0 rounded-md px-1.5 text-[11px] font-normal">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(92vw,42rem)] max-w-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>投递时间线</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[60vh] overflow-auto pr-1">
|
||||||
|
{loading && <div className="space-y-3">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-14 w-full" />)}</div>}
|
||||||
|
{!loading && events.length === 0 && <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">暂无投递事件</div>}
|
||||||
|
{!loading && events.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<div key={event.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{event.status && <SendQueueStatusBadge status={event.status} />}
|
||||||
|
<span>{event.message || event.event || event.eventType || "队列事件"}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatDateTime(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{typeof event.attemptCount === "number" && <span>尝试次数:{event.attemptCount}</span>}
|
||||||
|
{event.error && <span className="text-destructive">{event.error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendQueueSourceLabel(source: string) {
|
||||||
|
const normalized = source.toLowerCase()
|
||||||
|
if (normalized === "submission") return "SMTP Submission"
|
||||||
|
if (normalized === "webmail") return "Webmail"
|
||||||
|
if (normalized === "scheduled") return "定时发送"
|
||||||
|
return source || "未知"
|
||||||
|
}
|
||||||
|
|
||||||
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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user