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) {
|
||||
a := newTestApp(t)
|
||||
user, mailbox, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
|
||||
@@ -1381,6 +1568,43 @@ func TestSubmissionRequeuesDeliveredDuplicateMessageID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionRequeuesCanceledDuplicateMessageID(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
host, port, received := startCapturingSMTP(t, 1)
|
||||
a.cfg.SMTPHost = host
|
||||
a.cfg.SMTPPort = port
|
||||
user, mb, err := a.authenticateSubmission(context.Background(), "admin@lanqin.local", "ChangeMe123!")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := "From: admin@lanqin.local\r\nTo: person@example.com\r\nSubject: canceled resend\r\nMessage-ID: <canceled-requeue@example.test>\r\n\r\nbody"
|
||||
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,updated_at=? WHERE mailbox_id=? AND message_id=?`, sendQueueStatusCanceled, a.now().UTC().Format(time.RFC3339Nano), mb.ID, "<canceled-requeue@example.test>"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.submitSMTPMessage(context.Background(), user, mb, mb.Address, []string{"person@example.com"}, strings.NewReader(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.processDueSendQueue(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-received:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("requeued canceled message was not relayed")
|
||||
}
|
||||
var status string
|
||||
var attemptCount int
|
||||
if err := a.db.QueryRow(`SELECT status,attempt_count FROM send_queue WHERE mailbox_id=? AND message_id=?`, mb.ID, "<canceled-requeue@example.test>").Scan(&status, &attemptCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != sendQueueStatusDelivered || attemptCount != 1 {
|
||||
t.Fatalf("queue status=%q attempts=%d, want delivered attempts=1", status, attemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionAllowsAuthorizedAliasSendAs(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user