diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 995aa87..5b693f4 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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: "", + 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: "", + 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: "", + 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: "", + 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: "", + 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: \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, ""); 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, "").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() diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index a46ade4..2edea70 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -907,6 +907,214 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) { 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) { var req struct { MailboxID string `json:"mailboxId"` diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 9e5cc8e..fbc3b9f 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -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/messages/{id}", a.handleMailMessage) 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), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend) r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend) diff --git a/apps/api/internal/app/send_queue.go b/apps/api/internal/app/send_queue.go index 02a03ec..a4e32ae 100644 --- a/apps/api/internal/app/send_queue.go +++ b/apps/api/internal/app/send_queue.go @@ -16,12 +16,14 @@ const ( sendQueueStatusSending = "sending" sendQueueStatusDelivered = "delivered" sendQueueStatusFailed = "failed" + sendQueueStatusCanceled = "canceled" sendAuditAccepted = "accepted" sendAuditQueued = "queued" sendAuditDelivered = "delivered" sendAuditFailed = "failed" sendAuditRetry = "retry" + sendAuditCanceled = "canceled" sendSourceWebmail = "webmail" sendSourceSubmission = "submission" @@ -85,7 +87,7 @@ func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error return "", err } 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=?`, 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 { diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 4aec044..83c60c6 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -209,3 +209,38 @@ type MailStatsFolderCount struct { Unread int64 `json:"unread"` 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"` +} diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index f1cb9a6..b023a06 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -70,6 +70,45 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: export type DraftPayload = Omit & { attachments?: SendPayload["attachments"] } 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 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 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 } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b9b5241..17d5d01 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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" const REQUEST_TIMEOUT_MS = 15_000 @@ -131,6 +131,17 @@ export const api = { scheduledSends: (mailboxId?: string) => request>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), scheduleSend: (payload: ScheduleSendPayload) => request("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }), + sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => { + 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>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`) + }, + sendQueueAudit: (id: string) => request>(`/api/mail/send-queue/${id}/audit`), + retrySendQueue: (id: string) => request(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + cancelSendQueue: (id: string) => request(`/api/mail/send-queue/${id}`, { method: "DELETE" }), saveDraft: (payload: DraftPayload, id?: string) => request(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" }), markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 5fbf57a..de980d9 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -12,8 +12,8 @@ import Placeholder from "@tiptap/extension-placeholder" import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style" import { useNavigate } from "react-router-dom" 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 { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api" +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, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, PermissionLimits } from "@/lib/api" import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -61,7 +61,7 @@ const folderLabels: Record = { 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 MailView = "folder" | "starred" | "label" | "scheduled" +type MailView = "folder" | "starred" | "label" | "scheduled" | "sendQueue" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } @@ -69,6 +69,7 @@ type ComposeSendIntent = { title: string; description: string; confirmText: stri type MailMenuItem = | { type: "starred"; 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 } const filterLabels: Record = { @@ -103,6 +104,9 @@ export function MailPage() { const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") + const [sendQueueStatus, setSendQueueStatus] = React.useState("all") + const [sendQueueAuditId, setSendQueueAuditId] = React.useState("") + const [sendQueuePendingId, setSendQueuePendingId] = React.useState("") const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false) const [labelEditMode, setLabelEditMode] = 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 mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && hasPermission(user, "mail.stats.view") }) const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId && canScheduleMail, refetchInterval: 30000 }) + const canViewSendQueue = canReadMail + const sendQueue = useQuery({ queryKey: ["send-queue", activeMailboxId, sendQueueStatus], queryFn: () => api.sendQueue({ mailboxId: activeMailboxId, status: sendQueueStatus }), enabled: !!activeMailboxId && canViewSendQueue, refetchInterval: 15000 }) + const sendQueueAudit = useQuery({ queryKey: ["send-queue-audit", sendQueueAuditId], queryFn: () => api.sendQueueAudit(sendQueueAuditId), enabled: !!sendQueueAuditId && canViewSendQueue }) const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false const inboxProbe = useQuery({ queryKey: ["mail-notifications", activeMailboxId], @@ -148,7 +155,7 @@ export function MailPage() { }, initialPageParam: "", 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 }) function updateCachedMessage(id: string, patch: Partial) { @@ -276,6 +283,28 @@ export function MailPage() { onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), 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({ mutationFn: async (items: MailMessage[]) => { const unread = items.filter((message) => !message.isRead) @@ -403,6 +432,7 @@ export function MailPage() { qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), + qc.invalidateQueries({ queryKey: ["send-queue"] }), qc.invalidateQueries({ queryKey: ["mail-notifications"] }), ]).finally(() => { setLastAutoRefreshAt(new Date()) @@ -429,10 +459,16 @@ export function MailPage() { const visibleScheduledItems = scheduledQuery ? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery)) : 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 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 visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length @@ -453,6 +489,7 @@ export function MailPage() { qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), + qc.invalidateQueries({ queryKey: ["send-queue"] }), ]) } async function runBulkAction(action: BulkAction) { @@ -576,6 +613,13 @@ export function MailPage() { setMailFilter("all") setMobileSidebarOpen(false) } + function openSendQueue() { + setMailView("sendQueue") + setSelectedLabelId("") + setSelectedId(null) + setMailFilter("all") + setMobileSidebarOpen(false) + } function openLabel(labelId: string) { setSelectedLabelId(labelId) setMailView("label") @@ -654,9 +698,9 @@ export function MailPage() { {mailMenuItems.map((item) => ( 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} {!sidebarCollapsed && {item.label}} @@ -772,6 +816,23 @@ export function MailPage() { /> ) : mailView === "scheduled" ? ( + ) : mailView === "sendQueue" && canViewSendQueue ? ( + retrySendQueue.mutate(item)} + onCancel={(item) => cancelSendQueue.mutate(item)} + onAudit={(item) => setSendQueueAuditId(item.id)} + canMutate={canSendMail} + /> + ) : mailView === "sendQueue" ? ( + ) : isMobile || displayMode === "compact" ? ( openCompose()} disabled={!selectedMailbox} aria-label="写邮件">}
- setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" /> + setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="h-10 pl-9" />
)} @@ -924,7 +985,7 @@ export function MailPage() { {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} )} - {mailView !== "scheduled" && ( + {mailView !== "scheduled" && mailView !== "sendQueue" && ( <> {canOrganizeMail && } @@ -944,7 +1005,7 @@ export function MailPage() {
- setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" /> + setQuery(e.target.value)} placeholder={mailView === "sendQueue" ? "搜索发送队列" : mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
{contentView} @@ -954,7 +1015,13 @@ export function MailPage() { )} - { 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"] }) }} /> + { 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"] }) }} /> + { if (!open) setSendQueueAuditId("") }} + /> [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 }) 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: , count: starredCount } const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount } + const sendQueueItem: MailMenuItem = { type: "sendQueue", key: "send-queue", label: "发送队列", icon: , count: sendQueueCount } const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 - if (!includeScheduled) return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)] - return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)] + const specialItems: MailMenuItem[] = [starredItem] + if (includeScheduled) specialItems.push(scheduledItem) + if (includeSendQueue) specialItems.push(sendQueueItem) + return [...folderItems.slice(0, insertAt), ...specialItems, ...folderItems.slice(insertAt)] } function FolderSkeleton() { return
} @@ -996,6 +1066,7 @@ function MessageSkeleton() { return
{Array.from({ len function getEmptyMessage(mailView: MailView, folder: string, total: number) { if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" + if (mailView === "sendQueue") return total === 0 ? "发送队列为空" : "当前搜索没有匹配的发送任务" if (total > 0) return "当前筛选条件下没有邮件" if (mailView === "starred") 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 ( +
+
+
+
发送队列
+
{items.length} / {total} 个发送任务
+
+ +
+ + {loading && } + {!loading && items.length === 0 &&
{empty}
} + {!loading && items.map((item) => ( + onRetry(item)} + onCancel={() => onCancel(item)} + onAudit={() => onAudit(item)} + canMutate={canMutate} + /> + ))} +
+
+ ) +} + +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 ( +
+
+
+
+ {item.subject || "(无主题)"} + +
+
发给 {recipients}
+
+ 来源:{sendQueueSourceLabel(item.source)} + 尝试:{item.attemptCount}/{item.maxAttempts} + {item.nextAttemptAt && 下次:{formatDateTime(item.nextAttemptAt)}} +
+ {failure &&
{failure}
} +
+
+
更新时间
+
{formatDateTime(item.updatedAt || item.createdAt)}
+ {item.deliveredAt &&
投递于 {formatDateTime(item.deliveredAt)}
} +
+
+ + {canMutate && canRetry && ( + + )} + {canMutate && canCancel && ( + + )} +
+
+
+ ) +} + +function SendQueueStatusBadge({ status }: { status: SendQueueStatus }) { + const label = status === "queued" ? "排队中" : status === "sending" ? "发送中" : status === "delivered" ? "已投递" : status === "failed" ? "发送失败" : "已取消" + return ( + + {label} + + ) +} + +function SendQueueAuditDialog({ open, loading, events, onOpenChange }: { open: boolean; loading: boolean; events: SendQueueAuditEvent[]; onOpenChange: (open: boolean) => void }) { + return ( + + + + 投递时间线 + +
+ {loading &&
{Array.from({ length: 4 }).map((_, index) => )}
} + {!loading && events.length === 0 &&
暂无投递事件
} + {!loading && events.length > 0 && ( +
+ {events.map((event) => ( +
+
+
+ {event.status && } + {event.message || event.event || event.eventType || "队列事件"} +
+ {formatDateTime(event.createdAt)} +
+
+ {typeof event.attemptCount === "number" && 尝试次数:{event.attemptCount}} + {event.error && {event.error}} +
+
+ ))} +
+ )} +
+ + + +
+
+ ) +} + +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" function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) {