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

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