feat(open_api): 统一开放接口命名并记录来源

- 将公共 API 相关处理器、路由与测试重命名为 Open API,统一接口语义。
- 新增发送来源 open_api,并在发送审计与队列中保存该来源。
- 前端发送队列来源标签新增 Open API 显示。
This commit is contained in:
LanQin_
2026-06-29 16:52:07 +08:00
parent cb07d5d501
commit 3afbdc4d4a
6 changed files with 94 additions and 69 deletions
+39 -24
View File
@@ -1744,7 +1744,7 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) {
} }
} }
func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) { func TestOpenAPIDomainAndMailboxCRUD(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
@@ -1764,7 +1764,7 @@ func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) {
var domain Domain var domain Domain
if code := openAdmin.do("POST", "/api/open/domains", map[string]string{"name": "api.example.test"}, &domain); code != http.StatusCreated { if code := openAdmin.do("POST", "/api/open/domains", map[string]string{"name": "api.example.test"}, &domain); code != http.StatusCreated {
t.Fatalf("create public api domain code=%d domain=%+v", code, domain) t.Fatalf("create open api domain code=%d domain=%+v", code, domain)
} }
if domain.Name != "api.example.test" || domain.DKIMPublicKey == "" { if domain.Name != "api.example.test" || domain.DKIMPublicKey == "" {
t.Fatalf("domain=%+v", domain) t.Fatalf("domain=%+v", domain)
@@ -1773,20 +1773,20 @@ func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) {
Items []Domain `json:"items"` Items []Domain `json:"items"`
} }
if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK { if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK {
t.Fatalf("list public api domains code=%d", code) t.Fatalf("list open api domains code=%d", code)
} }
if len(domains.Items) < 2 { if len(domains.Items) < 2 {
t.Fatalf("domains=%+v", domains.Items) t.Fatalf("domains=%+v", domains.Items)
} }
var disabled Domain var disabled Domain
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "disabled"}, &disabled); code != http.StatusOK { if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "disabled"}, &disabled); code != http.StatusOK {
t.Fatalf("update public api domain code=%d domain=%+v", code, disabled) t.Fatalf("update open api domain code=%d domain=%+v", code, disabled)
} }
if disabled.Status != "disabled" { if disabled.Status != "disabled" {
t.Fatalf("domain status=%q", disabled.Status) t.Fatalf("domain status=%q", disabled.Status)
} }
if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK { if code := openAdmin.do("POST", "/api/open/domains/"+domain.ID, map[string]string{"status": "active"}, &domain); code != http.StatusOK {
t.Fatalf("reactivate public api domain code=%d domain=%+v", code, domain) t.Fatalf("reactivate open api domain code=%d domain=%+v", code, domain)
} }
var mailbox Mailbox var mailbox Mailbox
@@ -1797,7 +1797,7 @@ func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) {
"password": "Password123!", "password": "Password123!",
"quotaMb": 256, "quotaMb": 256,
}, &mailbox); code != http.StatusCreated { }, &mailbox); code != http.StatusCreated {
t.Fatalf("create public api mailbox code=%d mailbox=%+v", code, mailbox) t.Fatalf("create open api mailbox code=%d mailbox=%+v", code, mailbox)
} }
if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 { if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 {
t.Fatalf("mailbox=%+v", mailbox) t.Fatalf("mailbox=%+v", mailbox)
@@ -1806,30 +1806,32 @@ func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) {
Items []Mailbox `json:"items"` Items []Mailbox `json:"items"`
} }
if code := openAdmin.do("GET", "/api/open/mailboxes", nil, &mailboxes); code != http.StatusOK { if code := openAdmin.do("GET", "/api/open/mailboxes", nil, &mailboxes); code != http.StatusOK {
t.Fatalf("list public api mailboxes code=%d", code) t.Fatalf("list open api mailboxes code=%d", code)
} }
if len(mailboxes.Items) < 2 { if len(mailboxes.Items) < 2 {
t.Fatalf("mailboxes=%+v", mailboxes.Items) t.Fatalf("mailboxes=%+v", mailboxes.Items)
} }
var updated Mailbox var updated Mailbox
if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"displayName": "Renamed API User", "quotaMb": 512, "status": "disabled"}, &updated); code != http.StatusOK { if code := openAdmin.do("POST", "/api/open/mailboxes/"+mailbox.ID, map[string]any{"displayName": "Renamed API User", "quotaMb": 512, "status": "disabled"}, &updated); code != http.StatusOK {
t.Fatalf("update public api mailbox code=%d mailbox=%+v", code, updated) t.Fatalf("update open api mailbox code=%d mailbox=%+v", code, updated)
} }
if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" { if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" {
t.Fatalf("updated mailbox=%+v", updated) t.Fatalf("updated mailbox=%+v", updated)
} }
var ok map[string]any var ok map[string]any
if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK { if code := openAdmin.do("DELETE", "/api/open/mailboxes/"+mailbox.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete public api mailbox code=%d body=%v", code, ok) t.Fatalf("delete open api mailbox code=%d body=%v", code, ok)
} }
if code := openAdmin.do("DELETE", "/api/open/domains/"+domain.ID, nil, &ok); code != http.StatusOK { if code := openAdmin.do("DELETE", "/api/open/domains/"+domain.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete public api domain code=%d body=%v", code, ok) t.Fatalf("delete open api domain code=%d body=%v", code, ok)
} }
} }
func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) { func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
ts := httptest.NewServer(a.Router()) ts := httptest.NewServer(a.Router())
defer ts.Close() defer ts.Close()
admin := &testClient{t: t, server: ts} admin := &testClient{t: t, server: ts}
@@ -1839,9 +1841,9 @@ func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) {
t.Fatalf("admin login code=%d body=%v", code, login) t.Fatalf("admin login code=%d body=%v", code, login)
} }
domainID := mustDefaultDomainID(t, a) domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "public-sender", "Public Sender", "Password123!", nil) sender := createTestMailbox(t, admin, domainID, "open-sender", "Open API Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "public-recipient", "Public Recipient", "Password123!", nil) recipient := createTestMailbox(t, admin, domainID, "open-recipient", "Open API Recipient", "Password123!", nil)
other := createTestMailbox(t, admin, domainID, "public-other", "Public Other", "Password123!", nil) other := createTestMailbox(t, admin, domainID, "open-other", "Open API Other", "Password123!", nil)
senderClient := &testClient{t: t, server: ts} senderClient := &testClient{t: t, server: ts}
if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK { if code := senderClient.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
@@ -1852,10 +1854,10 @@ func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) {
if code := senderClient.do("POST", "/api/open/send", map[string]any{ if code := senderClient.do("POST", "/api/open/send", map[string]any{
"mailboxId": sender.ID, "mailboxId": sender.ID,
"to": []string{recipient.Address}, "to": []string{recipient.Address},
"subject": "cookie-only public api send", "subject": "cookie-only open api send",
"text": "this should not authenticate", "text": "this should not authenticate",
}, &map[string]any{}); code != http.StatusUnauthorized { }, &map[string]any{}); code != http.StatusUnauthorized {
t.Fatalf("cookie-only public api send code=%d", code) t.Fatalf("cookie-only open api send code=%d", code)
} }
var sent struct { var sent struct {
ID string `json:"id"` ID string `json:"id"`
@@ -1871,14 +1873,27 @@ func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) {
if code := senderOpen.do("POST", "/api/open/send", map[string]any{ if code := senderOpen.do("POST", "/api/open/send", map[string]any{
"mailboxId": sender.ID, "mailboxId": sender.ID,
"to": []string{recipient.Address}, "to": []string{recipient.Address},
"subject": "public api send", "subject": "open api send",
"text": "hello from public api", "text": "hello from open api",
}, &sent); code != http.StatusCreated { }, &sent); code != http.StatusCreated {
t.Fatalf("public api send code=%d body=%+v", code, sent) t.Fatalf("open api send code=%d body=%+v", code, sent)
} }
if sent.ID == "" || sent.Status != sendAuditAccepted || sent.MessageID == "" || sent.MailboxAddress != sender.Address { if sent.ID == "" || sent.QueueID == "" || sent.Status != sendQueueStatusQueued || sent.MessageID == "" || sent.MailboxAddress != sender.Address {
t.Fatalf("sent response=%+v", sent) t.Fatalf("sent response=%+v", sent)
} }
var sendSource string
if err := a.db.QueryRow(`SELECT source FROM send_audit_events WHERE sent_message_id=? AND event=?`, sent.MessageID, sendAuditAccepted).Scan(&sendSource); err != nil {
t.Fatal(err)
}
if sendSource != sendSourceOpenAPI {
t.Fatalf("open api send audit source=%q, want %q", sendSource, sendSourceOpenAPI)
}
if err := a.db.QueryRow(`SELECT source FROM send_queue WHERE id=?`, sent.QueueID).Scan(&sendSource); err != nil {
t.Fatal(err)
}
if sendSource != sendSourceOpenAPI {
t.Fatalf("open api send queue source=%q, want %q", sendSource, sendSourceOpenAPI)
}
var status struct { var status struct {
ID string `json:"id"` ID string `json:"id"`
@@ -1891,9 +1906,9 @@ func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) {
Subject string `json:"subject"` Subject string `json:"subject"`
} }
if code := senderOpen.do("GET", "/api/open/send/"+sent.ID, nil, &status); code != http.StatusOK { if code := senderOpen.do("GET", "/api/open/send/"+sent.ID, nil, &status); code != http.StatusOK {
t.Fatalf("public api send status code=%d status=%+v", code, status) t.Fatalf("open api send status code=%d status=%+v", code, status)
} }
if status.ID != sent.ID || status.MessageID != sent.MessageID || status.Status != sendAuditAccepted { if status.ID != sent.QueueID || status.MessageID != sent.MessageID || status.Status != sendQueueStatusQueued {
t.Fatalf("status=%+v sent=%+v", status, sent) t.Fatalf("status=%+v sent=%+v", status, sent)
} }
@@ -1908,9 +1923,9 @@ func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) {
NextCursor string `json:"nextCursor"` NextCursor string `json:"nextCursor"`
} }
if code := recipientOpen.do("GET", "/api/open/mailboxes/"+recipient.ID+"/messages?folder=Inbox", nil, &inbox); code != http.StatusOK { if code := recipientOpen.do("GET", "/api/open/mailboxes/"+recipient.ID+"/messages?folder=Inbox", nil, &inbox); code != http.StatusOK {
t.Fatalf("public api mailbox messages code=%d inbox=%+v", code, inbox) t.Fatalf("open api mailbox messages code=%d inbox=%+v", code, inbox)
} }
if len(inbox.Items) != 1 || inbox.Items[0].Subject != "public api send" || inbox.Items[0].From != sender.Address { if len(inbox.Items) != 1 || inbox.Items[0].Subject != "open api send" || inbox.Items[0].From != sender.Address {
t.Fatalf("inbox=%+v", inbox.Items) t.Fatalf("inbox=%+v", inbox.Items)
} }
if code := recipientOpen.do("GET", "/api/open/mailboxes/"+other.ID+"/messages?folder=Inbox", nil, &map[string]any{}); code != http.StatusNotFound { if code := recipientOpen.do("GET", "/api/open/mailboxes/"+other.ID+"/messages?folder=Inbox", nil, &map[string]any{}); code != http.StatusNotFound {
+10 -2
View File
@@ -691,6 +691,14 @@ var errSenderNotAuthorized = errors.New("sender address is not authorized")
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded") var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
return a.sendMailWithSource(ctx, user, mb, req, sendSourceWebmail)
}
func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput, source string) (*MailMessage, error) {
source = strings.TrimSpace(source)
if source == "" {
source = sendSourceWebmail
}
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil { if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
return nil, err return nil, err
} }
@@ -742,8 +750,8 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
a.deleteMessage(ctx, sentID) a.deleteMessage(ctx, sentID)
return nil, fmt.Errorf("failed to store sent message in maildir: %w", err) return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
} }
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients}) a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil { if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
a.deleteMessage(ctx, sentID) a.deleteMessage(ctx, sentID)
return nil, fmt.Errorf("failed to enqueue delivery: %w", err) return nil, fmt.Errorf("failed to enqueue delivery: %w", err)
} }
@@ -13,7 +13,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
func (a *App) handlePublicAPIListDomains(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`) rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list domains") respondError(w, http.StatusInternalServerError, "failed to list domains")
@@ -36,7 +36,7 @@ func (a *App) handlePublicAPIListDomains(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, map[string]any{"items": items}) respondJSON(w, http.StatusOK, map[string]any{"items": items})
} }
func (a *App) handlePublicAPICreateDomain(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
} }
@@ -57,7 +57,7 @@ func (a *App) handlePublicAPICreateDomain(w http.ResponseWriter, r *http.Request
respondJSON(w, http.StatusCreated, domain) respondJSON(w, http.StatusCreated, domain)
} }
func (a *App) handlePublicAPIGetDomain(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIGetDomain(w http.ResponseWriter, r *http.Request) {
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id")) domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "domain not found") respondError(w, http.StatusNotFound, "domain not found")
@@ -66,7 +66,7 @@ func (a *App) handlePublicAPIGetDomain(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, domain) respondJSON(w, http.StatusOK, domain)
} }
func (a *App) handlePublicAPIUpdateDomain(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIUpdateDomain(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Status string `json:"status"` Status string `json:"status"`
} }
@@ -97,7 +97,7 @@ func (a *App) handlePublicAPIUpdateDomain(w http.ResponseWriter, r *http.Request
respondJSON(w, http.StatusOK, domain) respondJSON(w, http.StatusOK, domain)
} }
func (a *App) handlePublicAPIDeleteDomain(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIDeleteDomain(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
var count int var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil {
@@ -120,7 +120,7 @@ func (a *App) handlePublicAPIDeleteDomain(w http.ResponseWriter, r *http.Request
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) respondJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (a *App) handlePublicAPIListMailboxes(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`) FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`)
if err != nil { if err != nil {
@@ -144,7 +144,7 @@ func (a *App) handlePublicAPIListMailboxes(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusOK, map[string]any{"items": items}) respondJSON(w, http.StatusOK, map[string]any{"items": items})
} }
func (a *App) handlePublicAPICreateMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
DomainID string `json:"domainId"` DomainID string `json:"domainId"`
LocalPart string `json:"localPart"` LocalPart string `json:"localPart"`
@@ -203,7 +203,7 @@ func (a *App) handlePublicAPICreateMailbox(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusCreated, mailbox) respondJSON(w, http.StatusCreated, mailbox)
} }
func (a *App) handlePublicAPIGetMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIGetMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id")) mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
@@ -212,7 +212,7 @@ func (a *App) handlePublicAPIGetMailbox(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, mailbox) respondJSON(w, http.StatusOK, mailbox)
} }
func (a *App) handlePublicAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
current, err := a.mailboxByID(r.Context(), id) current, err := a.mailboxByID(r.Context(), id)
if err != nil { if err != nil {
@@ -271,7 +271,7 @@ func (a *App) handlePublicAPIUpdateMailbox(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusOK, mailbox) respondJSON(w, http.StatusOK, mailbox)
} }
func (a *App) handlePublicAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
var owner string var owner string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil { if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
@@ -318,7 +318,7 @@ func (a *App) handlePublicAPIDeleteMailbox(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) respondJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (a *App) handlePublicAPISendMail(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
var req mailComposeInput var req mailComposeInput
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
@@ -329,26 +329,26 @@ func (a *App) handlePublicAPISendMail(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req) msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI)
if err != nil { if err != nil {
respondSendError(w, err) respondSendError(w, err)
return return
} }
status := publicAPISendStatusFromMessage(msg, mb.Address) status := openAPISendStatusFromMessage(msg, mb.Address)
if msg.SendQueueID != "" { if msg.SendQueueID != "" {
if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil { if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil {
status = publicAPISendStatusFromQueue(item, mb.Address) status = openAPISendStatusFromQueue(item, mb.Address)
} }
} else { } else {
item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID) item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID)
if err == nil { if err == nil {
status = publicAPISendStatusFromQueue(item, mb.Address) status = openAPISendStatusFromQueue(item, mb.Address)
} }
} }
respondJSON(w, http.StatusCreated, status) respondJSON(w, http.StatusCreated, status)
} }
func (a *App) handlePublicAPISendStatus(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPISendStatus(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
id := strings.TrimSpace(chi.URLParam(r, "id")) id := strings.TrimSpace(chi.URLParam(r, "id"))
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID) item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
@@ -360,10 +360,10 @@ func (a *App) handlePublicAPISendStatus(w http.ResponseWriter, r *http.Request)
if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil { if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil {
mailboxAddress = mb.Address mailboxAddress = mb.Address
} }
respondJSON(w, http.StatusOK, publicAPISendStatusFromQueue(item, mailboxAddress)) respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(item, mailboxAddress))
return return
} }
msg, err := a.loadPublicAPISentMessageForUser(r.Context(), id, user.ID) msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
if err != nil { if err != nil {
respondError(w, http.StatusNotFound, "send item not found") respondError(w, http.StatusNotFound, "send item not found")
return return
@@ -372,18 +372,18 @@ func (a *App) handlePublicAPISendStatus(w http.ResponseWriter, r *http.Request)
if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil { if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil {
mailboxAddress = mb.Address mailboxAddress = mb.Address
} }
respondJSON(w, http.StatusOK, publicAPISendStatusFromMessage(msg, mailboxAddress)) respondJSON(w, http.StatusOK, openAPISendStatusFromMessage(msg, mailboxAddress))
} }
func (a *App) handlePublicAPIMailboxMessages(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
mailboxID := strings.TrimSpace(chi.URLParam(r, "id")) mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil { if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
limit := parsePublicAPILimit(r, 30, 100) limit := parseOpenAPILimit(r, 30, 100)
offset := parsePublicAPIOffset(r) offset := parseOpenAPIOffset(r)
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
if folder == "" { if folder == "" {
folder = "Inbox" folder = "Inbox"
@@ -458,7 +458,7 @@ func scanMailbox(row mailboxScanner) (Mailbox, error) {
return item, nil return item, nil
} }
type publicAPISendStatus struct { type openAPISendStatus struct {
ID string `json:"id"` ID string `json:"id"`
QueueID string `json:"queueId,omitempty"` QueueID string `json:"queueId,omitempty"`
Status string `json:"status"` Status string `json:"status"`
@@ -477,8 +477,8 @@ type publicAPISendStatus struct {
DeliveredAt *time.Time `json:"deliveredAt,omitempty"` DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
} }
func publicAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) publicAPISendStatus { func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus {
return publicAPISendStatus{ return openAPISendStatus{
ID: item.ID, ID: item.ID,
QueueID: item.ID, QueueID: item.ID,
Status: item.Status, Status: item.Status,
@@ -498,10 +498,10 @@ func publicAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) pu
} }
} }
func publicAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) publicAPISendStatus { func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus {
recipients := append(append([]string{}, msg.To...), msg.CC...) recipients := append(append([]string{}, msg.To...), msg.CC...)
recipients = append(recipients, msg.BCC...) recipients = append(recipients, msg.BCC...)
return publicAPISendStatus{ return openAPISendStatus{
ID: msg.ID, ID: msg.ID,
Status: sendAuditAccepted, Status: sendAuditAccepted,
MessageID: msg.ID, MessageID: msg.ID,
@@ -623,7 +623,7 @@ func (a *App) loadSendQueueEntryForSentMessage(ctx context.Context, sentMessageI
return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID) return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID)
} }
func (a *App) loadPublicAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) { func (a *App) loadOpenAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) {
var messageID string var messageID string
err := a.db.QueryRowContext(ctx, `SELECT m.id err := a.db.QueryRowContext(ctx, `SELECT m.id
FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id
@@ -635,7 +635,7 @@ func (a *App) loadPublicAPISentMessageForUser(ctx context.Context, id, userID st
return a.messageByID(ctx, messageID, false) return a.messageByID(ctx, messageID, false)
} }
func parsePublicAPILimit(r *http.Request, defaultLimit, maxLimit int) int { func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
limit, err := strconv.Atoi(r.URL.Query().Get("limit")) limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 { if err != nil || limit <= 0 {
return defaultLimit return defaultLimit
@@ -646,7 +646,7 @@ func parsePublicAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
return limit return limit
} }
func parsePublicAPIOffset(r *http.Request) int { func parseOpenAPIOffset(r *http.Request) int {
cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
if cursor == "" { if cursor == "" {
return 0 return 0
+13 -13
View File
@@ -77,19 +77,19 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.requireAPIToken) r.Use(a.requireAPIToken)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains", a.handlePublicAPIListDomains) r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains", a.handleOpenAPIListDomains)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/open/domains", a.handlePublicAPICreateDomain) r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/open/domains", a.handleOpenAPICreateDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains/{id}", a.handlePublicAPIGetDomain) r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains/{id}", a.handleOpenAPIGetDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/open/domains/{id}", a.handlePublicAPIUpdateDomain) r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/open/domains/{id}", a.handleOpenAPIUpdateDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/open/domains/{id}", a.handlePublicAPIDeleteDomain) r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/open/domains/{id}", a.handleOpenAPIDeleteDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes", a.handlePublicAPIListMailboxes) r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes", a.handleOpenAPIListMailboxes)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/open/mailboxes", a.handlePublicAPICreateMailbox) r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/open/mailboxes", a.handleOpenAPICreateMailbox)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes/{id}", a.handlePublicAPIGetMailbox) r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes/{id}", a.handleOpenAPIGetMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/open/mailboxes/{id}", a.handlePublicAPIUpdateMailbox) r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/open/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/open/mailboxes/{id}", a.handlePublicAPIDeleteMailbox) r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/open/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
r.With(a.requirePermission(PermissionMailSend)).Post("/open/send", a.handlePublicAPISendMail) r.With(a.requirePermission(PermissionMailSend)).Post("/open/send", a.handleOpenAPISendMail)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/send/{id}", a.handlePublicAPISendStatus) r.With(a.requirePermission(PermissionMailRead)).Get("/open/send/{id}", a.handleOpenAPISendStatus)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/mailboxes/{id}/messages", a.handlePublicAPIMailboxMessages) r.With(a.requirePermission(PermissionMailRead)).Get("/open/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
+1
View File
@@ -27,6 +27,7 @@ const (
sendSourceWebmail = "webmail" sendSourceWebmail = "webmail"
sendSourceSubmission = "submission" sendSourceSubmission = "submission"
sendSourceOpenAPI = "open_api"
sendQueueStaleAfter = 15 * time.Minute sendQueueStaleAfter = 15 * time.Minute
sendQueueConcurrency = 4 sendQueueConcurrency = 4
+1
View File
@@ -1921,6 +1921,7 @@ function sendQueueSourceLabel(source: string) {
const normalized = source.toLowerCase() const normalized = source.toLowerCase()
if (normalized === "submission") return "SMTP Submission" if (normalized === "submission") return "SMTP Submission"
if (normalized === "webmail") return "Webmail" if (normalized === "webmail") return "Webmail"
if (normalized === "open_api") return "Open API"
if (normalized === "scheduled") return "定时发送" if (normalized === "scheduled") return "定时发送"
return source || "未知" return source || "未知"
} }