diff --git a/apps/api/internal/app/api_token_handlers.go b/apps/api/internal/app/api_token_handlers.go new file mode 100644 index 0000000..ac91e84 --- /dev/null +++ b/apps/api/internal/app/api_token_handlers.go @@ -0,0 +1,211 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const defaultAPITokenTTL = 90 * 24 * time.Hour + +func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,created_at,updated_at + FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list api tokens") + return + } + defer rows.Close() + items := []APIToken{} + for rows.Next() { + item, err := scanAPIToken(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan api tokens") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to list api tokens") + return + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req struct { + Name string `json:"name"` + ExpiresAt string `json:"expiresAt"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + badRequest(w, errors.New("name is required")) + return + } + if len([]rune(name)) > 80 { + badRequest(w, errors.New("name cannot exceed 80 characters")) + return + } + expiresAt, err := parseOptionalFutureTime(req.ExpiresAt, a.now().UTC()) + if err != nil { + badRequest(w, err) + return + } + if expiresAt == nil { + defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL) + expiresAt = &defaultExpiry + } + id := newID("apt") + token := "lq_" + randomToken() + now := a.now().UTC().Format(time.RFC3339Nano) + var expiresValue any + if expiresAt != nil { + expiresValue = expiresAt.UTC().Format(time.RFC3339Nano) + } + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, now, now); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create api token") + return + } + item, err := a.apiTokenByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load api token") + return + } + respondJSON(w, http.StatusCreated, map[string]any{"token": token, "item": item}) +} + +func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + respondError(w, http.StatusNotFound, "api token not found") + return + } + var req struct { + Name *string `json:"name"` + ExpiresAt *string `json:"expiresAt"` + Disabled *bool `json:"disabled"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + current, err := a.apiTokenByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusNotFound, "api token not found") + return + } + name := current.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + if name == "" { + badRequest(w, errors.New("name is required")) + return + } + if len([]rune(name)) > 80 { + badRequest(w, errors.New("name cannot exceed 80 characters")) + return + } + } + var expiresValue any + if current.ExpiresAt != nil { + expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano) + } + if req.ExpiresAt != nil { + expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC()) + if err != nil { + badRequest(w, err) + return + } + expiresValue = nil + if expiresAt != nil { + expiresValue = expiresAt.UTC().Format(time.RFC3339Nano) + } + } + disabled := current.Disabled + if req.Disabled != nil { + disabled = *req.Disabled + } + res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,updated_at=? WHERE id=? AND user_id=?`, + name, expiresValue, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update api token") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "api token not found") + return + } + item, err := a.apiTokenByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load api token") + return + } + respondJSON(w, http.StatusOK, item) +} + +func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + res, err := a.db.ExecContext(r.Context(), `DELETE FROM api_tokens WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete api token") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "api token not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,created_at,updated_at + FROM api_tokens WHERE id=? AND user_id=?`, id, userID) + return scanAPIToken(row) +} + +type apiTokenScanner interface{ Scan(dest ...any) error } + +func scanAPIToken(row apiTokenScanner) (APIToken, error) { + var item APIToken + var lastUsed, expires sql.NullString + var disabled int + var created, updated string + if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &created, &updated); err != nil { + return item, err + } + item.LastUsedAt = nullableTime(lastUsed) + item.ExpiresAt = nullableTime(expires) + item.Disabled = intBool(disabled) + item.CreatedAt = parseTime(created) + item.UpdatedAt = parseTime(updated) + return item, nil +} + +func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, errors.New("expiresAt must be an RFC3339 timestamp") + } + t = t.UTC() + if !t.After(now) { + return nil, errors.New("expiresAt must be in the future") + } + return &t, nil +} diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 41fbbf8..365cfe0 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -163,6 +163,19 @@ func (a *App) migrate(ctx context.Context) error { expires_at TEXT NOT NULL, created_at TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + last_used_at TEXT, + expires_at TEXT, + disabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, created_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash)`, `CREATE TABLE IF NOT EXISTS system_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index d6b684f..07ce249 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -218,6 +218,7 @@ type testClient struct { t *testing.T server *httptest.Server cookie *http.Cookie + bearer string } func (c *testClient) do(method, path string, body any, out any) int { @@ -237,6 +238,9 @@ func (c *testClient) do(method, path string, body any, out any) int { if c.cookie != nil { req.AddCookie(c.cookie) } + if c.bearer != "" { + req.Header.Set("Authorization", "Bearer "+c.bearer) + } resp, err := http.DefaultClient.Do(req) if err != nil { c.t.Fatal(err) @@ -279,6 +283,21 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis return mailbox } +func createTestAPIToken(t *testing.T, client *testClient, name string) string { + t.Helper() + var resp struct { + Token string `json:"token"` + Item APIToken `json:"item"` + } + if code := client.do("POST", "/api/me/api-tokens", map[string]string{"name": name}, &resp); code != http.StatusCreated { + t.Fatalf("create api token code=%d resp=%+v", code, resp) + } + if resp.Token == "" || resp.Item.ID == "" || resp.Item.Name != name { + t.Fatalf("api token response=%+v", resp) + } + return resp.Token +} + func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions []string) PermissionGroup { t.Helper() var group PermissionGroup @@ -1649,6 +1668,246 @@ func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) { } } +func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK { + t.Fatalf("login code=%d", code) + } + var created struct { + Token string `json:"token"` + Item APIToken `json:"item"` + } + if code := admin.do("POST", "/api/me/api-tokens", map[string]string{"name": "integration-test"}, &created); code != http.StatusCreated { + t.Fatalf("create api token code=%d resp=%+v", code, created) + } + if !strings.HasPrefix(created.Token, "lq_") || created.Item.ID == "" || created.Item.Name != "integration-test" || created.Item.ExpiresAt == nil { + t.Fatalf("created token response=%+v", created) + } + if remaining := time.Until(*created.Item.ExpiresAt); remaining < 89*24*time.Hour || remaining > 91*24*time.Hour { + t.Fatalf("created token default expiry=%s, remaining=%s", created.Item.ExpiresAt, remaining) + } + var storedHash string + if err := a.db.QueryRow(`SELECT token_hash FROM api_tokens WHERE id=?`, created.Item.ID).Scan(&storedHash); err != nil { + t.Fatal(err) + } + if storedHash == created.Token || storedHash != hashToken(created.Token) { + t.Fatalf("stored token hash=%q token=%q", storedHash, created.Token) + } + + openAdmin := &testClient{t: t, server: ts, bearer: created.Token} + var domains struct { + Items []Domain `json:"items"` + } + if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK { + t.Fatalf("open api with bearer token code=%d", code) + } + var listed struct { + Items []APIToken `json:"items"` + } + if code := admin.do("GET", "/api/me/api-tokens", nil, &listed); code != http.StatusOK { + t.Fatalf("list api tokens code=%d", code) + } + if len(listed.Items) != 1 || listed.Items[0].ID != created.Item.ID || listed.Items[0].LastUsedAt == nil { + t.Fatalf("listed tokens=%+v", listed.Items) + } + + disabled := true + var updated APIToken + if code := admin.do("POST", "/api/me/api-tokens/"+created.Item.ID, map[string]any{"disabled": disabled}, &updated); code != http.StatusOK { + t.Fatalf("disable api token code=%d item=%+v", code, updated) + } + if !updated.Disabled { + t.Fatalf("updated token should be disabled: %+v", updated) + } + if code := openAdmin.do("GET", "/api/open/domains", nil, &map[string]any{}); code != http.StatusUnauthorized { + t.Fatalf("disabled bearer token code=%d", code) + } + if code := admin.do("DELETE", "/api/me/api-tokens/"+created.Item.ID, nil, &map[string]any{}); code != http.StatusOK { + t.Fatalf("delete api token code=%d", code) + } +} + +func TestPublicAPIDomainAndMailboxCRUD(t *testing.T) { + a := newTestApp(t) + 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("login code=%d body=%v", code, login) + } + adminToken := createTestAPIToken(t, admin, "admin-open-api") + openAdmin := &testClient{t: t, server: ts, bearer: adminToken} + + var authErr map[string]any + if code := admin.do("GET", "/api/open/domains", nil, &authErr); code != http.StatusUnauthorized { + t.Fatalf("cookie-only open api code=%d body=%v", code, authErr) + } + + var domain Domain + 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) + } + if domain.Name != "api.example.test" || domain.DKIMPublicKey == "" { + t.Fatalf("domain=%+v", domain) + } + var domains struct { + Items []Domain `json:"items"` + } + if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK { + t.Fatalf("list public api domains code=%d", code) + } + if len(domains.Items) < 2 { + t.Fatalf("domains=%+v", domains.Items) + } + var disabled Domain + 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) + } + if disabled.Status != "disabled" { + 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 { + t.Fatalf("reactivate public api domain code=%d domain=%+v", code, domain) + } + + var mailbox Mailbox + if code := openAdmin.do("POST", "/api/open/mailboxes", map[string]any{ + "domainId": domain.ID, + "localPart": "api-user", + "displayName": "API User", + "password": "Password123!", + "quotaMb": 256, + }, &mailbox); code != http.StatusCreated { + t.Fatalf("create public api mailbox code=%d mailbox=%+v", code, mailbox) + } + if mailbox.Address != "api-user@api.example.test" || mailbox.QuotaMB != 256 { + t.Fatalf("mailbox=%+v", mailbox) + } + var mailboxes struct { + Items []Mailbox `json:"items"` + } + if code := openAdmin.do("GET", "/api/open/mailboxes", nil, &mailboxes); code != http.StatusOK { + t.Fatalf("list public api mailboxes code=%d", code) + } + if len(mailboxes.Items) < 2 { + t.Fatalf("mailboxes=%+v", mailboxes.Items) + } + 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 { + t.Fatalf("update public api mailbox code=%d mailbox=%+v", code, updated) + } + if updated.DisplayName != "Renamed API User" || updated.QuotaMB != 512 || updated.Status != "disabled" { + t.Fatalf("updated mailbox=%+v", updated) + } + var ok map[string]any + 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) + } + 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) + } +} + +func TestPublicAPISendStatusAndMailboxMessages(t *testing.T) { + a := newTestApp(t) + stopTestWorkers(a) + 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 body=%v", code, login) + } + domainID := mustDefaultDomainID(t, a) + sender := createTestMailbox(t, admin, domainID, "public-sender", "Public Sender", "Password123!", nil) + recipient := createTestMailbox(t, admin, domainID, "public-recipient", "Public Recipient", "Password123!", nil) + other := createTestMailbox(t, admin, domainID, "public-other", "Public Other", "Password123!", nil) + + 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 { + t.Fatalf("sender login code=%d body=%v", code, login) + } + senderToken := createTestAPIToken(t, senderClient, "sender-open-api") + senderOpen := &testClient{t: t, server: ts, bearer: senderToken} + if code := senderClient.do("POST", "/api/open/send", map[string]any{ + "mailboxId": sender.ID, + "to": []string{recipient.Address}, + "subject": "cookie-only public api send", + "text": "this should not authenticate", + }, &map[string]any{}); code != http.StatusUnauthorized { + t.Fatalf("cookie-only public api send code=%d", code) + } + var sent struct { + ID string `json:"id"` + QueueID string `json:"queueId"` + Status string `json:"status"` + MessageID string `json:"messageId"` + RFCMessageID string `json:"rfcMessageId"` + MailboxID string `json:"mailboxId"` + MailboxAddress string `json:"mailboxAddress"` + Subject string `json:"subject"` + CreatedAt time.Time `json:"createdAt"` + } + if code := senderOpen.do("POST", "/api/open/send", map[string]any{ + "mailboxId": sender.ID, + "to": []string{recipient.Address}, + "subject": "public api send", + "text": "hello from public api", + }, &sent); code != http.StatusCreated { + t.Fatalf("public api send code=%d body=%+v", code, sent) + } + if sent.ID == "" || sent.Status != sendAuditAccepted || sent.MessageID == "" || sent.MailboxAddress != sender.Address { + t.Fatalf("sent response=%+v", sent) + } + + var status struct { + ID string `json:"id"` + QueueID string `json:"queueId"` + Status string `json:"status"` + MessageID string `json:"messageId"` + RFCMessageID string `json:"rfcMessageId"` + MailboxID string `json:"mailboxId"` + MailboxAddress string `json:"mailboxAddress"` + Subject string `json:"subject"` + } + 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) + } + if status.ID != sent.ID || status.MessageID != sent.MessageID || status.Status != sendAuditAccepted { + t.Fatalf("status=%+v sent=%+v", status, sent) + } + + recipientClient := &testClient{t: t, server: ts} + if code := recipientClient.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("recipient login code=%d body=%v", code, login) + } + recipientToken := createTestAPIToken(t, recipientClient, "recipient-open-api") + recipientOpen := &testClient{t: t, server: ts, bearer: recipientToken} + var inbox struct { + Items []MailMessage `json:"items"` + NextCursor string `json:"nextCursor"` + } + 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) + } + if len(inbox.Items) != 1 || inbox.Items[0].Subject != "public api send" || inbox.Items[0].From != sender.Address { + 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 { + t.Fatalf("cross-user mailbox read code=%d", code) + } + if code := recipientOpen.do("GET", "/api/open/send/"+sent.ID, nil, &map[string]any{}); code != http.StatusNotFound { + t.Fatalf("cross-user send status code=%d", code) + } +} + func TestSendQueueRecoversStaleSendingItems(t *testing.T) { a := newTestApp(t) stopTestWorkers(a) diff --git a/apps/api/internal/app/public_api_handlers.go b/apps/api/internal/app/public_api_handlers.go new file mode 100644 index 0000000..bef1e57 --- /dev/null +++ b/apps/api/internal/app/public_api_handlers.go @@ -0,0 +1,659 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +func (a *App) handlePublicAPIListDomains(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`) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list domains") + return + } + defer rows.Close() + items := []Domain{} + for rows.Next() { + item, err := scanDomain(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan domains") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to list domains") + return + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handlePublicAPICreateDomain(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + id, err := a.createDomainTx(r.Context(), nil, req.Name) + if err != nil { + badRequest(w, err) + return + } + domain, err := a.domainByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load domain") + return + } + respondJSON(w, http.StatusCreated, domain) +} + +func (a *App) handlePublicAPIGetDomain(w http.ResponseWriter, r *http.Request) { + domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "domain not found") + return + } + respondJSON(w, http.StatusOK, domain) +} + +func (a *App) handlePublicAPIUpdateDomain(w http.ResponseWriter, r *http.Request) { + var req struct { + Status string `json:"status"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + status := strings.TrimSpace(req.Status) + if status != "active" && status != "disabled" { + badRequest(w, errors.New("invalid status")) + return + } + id := chi.URLParam(r, "id") + res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`, status, a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update domain") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "domain not found") + return + } + domain, err := a.domainByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load domain") + return + } + respondJSON(w, http.StatusOK, domain) +} + +func (a *App) handlePublicAPIDeleteDomain(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var count int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check domain") + return + } + if count > 0 { + badRequest(w, errors.New("domain still has mailboxes")) + return + } + res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete domain") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "domain not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handlePublicAPIListMailboxes(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 + FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list mailboxes") + return + } + defer rows.Close() + items := []Mailbox{} + for rows.Next() { + item, err := scanMailbox(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan mailboxes") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to list mailboxes") + return + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handlePublicAPICreateMailbox(w http.ResponseWriter, r *http.Request) { + var req struct { + DomainID string `json:"domainId"` + LocalPart string `json:"localPart"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + QuotaMB int `json:"quotaMb"` + OwnerEmail string `json:"ownerEmail"` + UserID string `json:"userId"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if err := requireString("domainId", req.DomainID); err != nil { + badRequest(w, err) + return + } + if err := requireString("localPart", req.LocalPart); err != nil { + badRequest(w, err) + return + } + if len(req.Password) < 8 { + badRequest(w, errors.New("password must be at least 8 characters")) + return + } + domain, err := a.domainByID(r.Context(), req.DomainID) + if err != nil { + respondError(w, http.StatusNotFound, "domain not found") + return + } + localPart := normalizeLocalPart(req.LocalPart) + if localPart == "" { + badRequest(w, errors.New("localPart is required")) + return + } + address := localPart + "@" + domain.Name + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = address + } + userID, err := a.resolveMailboxOwner(r, req.UserID, req.OwnerEmail, address, displayName, req.Password) + if err != nil { + respondMailboxOwnerError(w, err) + return + } + mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, localPart, displayName, req.Password, req.QuotaMB, "active") + if err != nil { + badRequest(w, err) + return + } + mailbox, err := a.mailboxByID(r.Context(), mailboxID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailbox") + return + } + respondJSON(w, http.StatusCreated, mailbox) +} + +func (a *App) handlePublicAPIGetMailbox(w http.ResponseWriter, r *http.Request) { + mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + respondJSON(w, http.StatusOK, mailbox) +} + +func (a *App) handlePublicAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + current, err := a.mailboxByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + var req struct { + DisplayName string `json:"displayName"` + QuotaMB int `json:"quotaMb"` + Status string `json:"status"` + UserID string `json:"userId"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = current.DisplayName + } + quotaMB := req.QuotaMB + if quotaMB <= 0 { + quotaMB = current.QuotaMB + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = current.Status + } + if status != "active" && status != "disabled" { + badRequest(w, errors.New("invalid status")) + return + } + userID := strings.TrimSpace(req.UserID) + if userID == "" { + userID = current.UserID + } + if err := a.ensureActiveUserExists(r.Context(), userID); err != nil { + respondMailboxOwnerError(w, err) + return + } + res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, + userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update mailbox") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + mailbox, err := a.mailboxByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailbox") + return + } + respondJSON(w, http.StatusOK, mailbox) +} + +func (a *App) handlePublicAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var owner string + if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + current := currentUser(r) + if current != nil && owner == current.ID { + var count int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check mailbox") + return + } + if count <= 1 { + badRequest(w, errors.New("cannot delete your last mailbox")) + return + } + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailbox messages") + return + } + messageIDs := []string{} + for rows.Next() { + var messageID string + if rows.Scan(&messageID) == nil { + messageIDs = append(messageIDs, messageID) + } + } + rows.Close() + for _, messageID := range messageIDs { + a.deleteMessage(r.Context(), messageID) + } + res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete mailbox") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handlePublicAPISendMail(w http.ResponseWriter, r *http.Request) { + var req mailComposeInput + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID) + if err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req) + if err != nil { + respondSendError(w, err) + return + } + status := publicAPISendStatusFromMessage(msg, mb.Address) + if msg.SendQueueID != "" { + if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil { + status = publicAPISendStatusFromQueue(item, mb.Address) + } + } else { + item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID) + if err == nil { + status = publicAPISendStatusFromQueue(item, mb.Address) + } + } + respondJSON(w, http.StatusCreated, status) +} + +func (a *App) handlePublicAPISendStatus(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 { + item, err = a.loadSendQueueEntryForSentMessage(r.Context(), id, user.ID) + } + if err == nil { + mailboxAddress := "" + if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil { + mailboxAddress = mb.Address + } + respondJSON(w, http.StatusOK, publicAPISendStatusFromQueue(item, mailboxAddress)) + return + } + msg, err := a.loadPublicAPISentMessageForUser(r.Context(), id, user.ID) + if err != nil { + respondError(w, http.StatusNotFound, "send item not found") + return + } + mailboxAddress := "" + if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil { + mailboxAddress = mb.Address + } + respondJSON(w, http.StatusOK, publicAPISendStatusFromMessage(msg, mailboxAddress)) +} + +func (a *App) handlePublicAPIMailboxMessages(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + mailboxID := strings.TrimSpace(chi.URLParam(r, "id")) + if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + limit := parsePublicAPILimit(r, 30, 100) + offset := parsePublicAPIOffset(r) + folder := strings.TrimSpace(r.URL.Query().Get("folder")) + if folder == "" { + folder = "Inbox" + } + where := "m.mailbox_id=?" + args := []any{mailboxID} + if folder != "" && !strings.EqualFold(folder, "all") { + where += " AND lower(f.name)=lower(?)" + args = append(args, folder) + } + if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" { + where += " AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)" + like := "%" + q + "%" + args = append(args, like, like, like, like, like, like) + } + args = append(args, limit+1, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + FROM messages m JOIN folders f ON f.id=m.folder_id + WHERE `+where+` + ORDER BY m.received_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load messages") + return + } + defer rows.Close() + items := []MailMessage{} + for rows.Next() { + item, err := scanMessageSummary(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan messages") + return + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load messages") + return + } + nextCursor := "" + if len(items) > limit { + items = items[:limit] + nextCursor = strconv.Itoa(offset + limit) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor}) +} + +type domainScanner interface{ Scan(dest ...any) error } + +func scanDomain(row domainScanner) (Domain, error) { + var item Domain + var checked sql.NullString + var created string + err := row.Scan(&item.ID, &item.Name, &item.Status, &item.DKIMSelector, &item.DKIMPublicKey, &item.DNSStatus, &checked, &created) + if err != nil { + return item, err + } + item.DNSCheckedAt = nullableTime(checked) + item.CreatedAt = parseTime(created) + return item, nil +} + +type mailboxScanner interface{ Scan(dest ...any) error } + +func scanMailbox(row mailboxScanner) (Mailbox, error) { + var item Mailbox + var created string + err := row.Scan(&item.ID, &item.UserID, &item.UserEmail, &item.DomainID, &item.LocalPart, &item.Address, &item.DisplayName, &item.QuotaMB, &item.Status, &created) + if err != nil { + return item, err + } + item.CreatedAt = parseTime(created) + return item, nil +} + +type publicAPISendStatus struct { + ID string `json:"id"` + QueueID string `json:"queueId,omitempty"` + Status string `json:"status"` + MessageID string `json:"messageId"` + RFCMessageID string `json:"rfcMessageId"` + MailboxID string `json:"mailboxId"` + MailboxAddress string `json:"mailboxAddress,omitempty"` + Subject string `json:"subject,omitempty"` + Recipients []string `json:"recipients,omitempty"` + AttemptCount int `json:"attemptCount,omitempty"` + MaxAttempts int `json:"maxAttempts,omitempty"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + LastError string `json:"lastError,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + DeliveredAt *time.Time `json:"deliveredAt,omitempty"` +} + +func publicAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) publicAPISendStatus { + return publicAPISendStatus{ + ID: item.ID, + QueueID: item.ID, + Status: item.Status, + MessageID: item.SentMessageID, + RFCMessageID: item.MessageID, + MailboxID: item.MailboxID, + MailboxAddress: mailboxAddress, + Subject: item.Subject, + Recipients: item.Recipients, + AttemptCount: item.AttemptCount, + MaxAttempts: item.MaxAttempts, + NextAttemptAt: timePtr(item.NextAttemptAt), + LastError: item.LastError, + CreatedAt: item.CreatedAt, + UpdatedAt: timePtr(item.UpdatedAt), + DeliveredAt: item.DeliveredAt, + } +} + +func publicAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) publicAPISendStatus { + recipients := append(append([]string{}, msg.To...), msg.CC...) + recipients = append(recipients, msg.BCC...) + return publicAPISendStatus{ + ID: msg.ID, + Status: sendAuditAccepted, + MessageID: msg.ID, + RFCMessageID: msg.MessageID, + MailboxID: msg.MailboxID, + MailboxAddress: mailboxAddress, + Subject: msg.Subject, + Recipients: dedupeEmails(recipients), + CreatedAt: msg.ReceivedAt, + } +} + +func timePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func (a *App) resolveMailboxOwner(r *http.Request, userID, ownerEmail, address, displayName, password string) (string, error) { + userID = strings.TrimSpace(userID) + if userID != "" { + if err := a.ensureActiveUserExists(r.Context(), userID); err != nil { + return "", err + } + return userID, nil + } + email := normalizeEmail(ownerEmail) + if email == "" { + email = address + } + if !strings.Contains(email, "@") { + return "", errors.New("invalid owner email") + } + var existing string + err := a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing) + if err == nil { + return existing, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", err + } + return a.createMailboxOwnerUser(r, email, displayName, password) +} + +func (a *App) createMailboxOwnerUser(r *http.Request, email, displayName, password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + userID := newID("usr") + now := a.now().UTC().Format(time.RFC3339Nano) + if displayName == "" { + displayName = email + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(hash), 0, now, now) + if err != nil { + return "", err + } + return userID, nil +} + +func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error { + var disabled int + if err := a.db.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errNotFound + } + return err + } + if intBool(disabled) { + return errors.New("owner user is disabled") + } + return nil +} + +func respondMailboxOwnerError(w http.ResponseWriter, err error) { + if errors.Is(err, errNotFound) { + respondError(w, http.StatusNotFound, "owner user not found") + return + } + if err != nil { + badRequest(w, err) + return + } +} + +func respondSendError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errNoRecipients), errors.Is(err, errInvalidMIME), errors.Is(err, errAttachmentTooLarge): + badRequest(w, err) + case errors.Is(err, errSMTPRateLimited): + respondError(w, http.StatusTooManyRequests, err.Error()) + case errors.Is(err, errSenderNotAuthorized): + respondError(w, http.StatusForbidden, err.Error()) + case errors.Is(err, errMailboxQuotaExceeded): + respondError(w, http.StatusInsufficientStorage, err.Error()) + default: + respondError(w, http.StatusInternalServerError, err.Error()) + } +} + +func (a *App) loadLatestSendQueueForMessage(ctx context.Context, sentMessageID, 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.sent_message_id=? AND mb.user_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, userID) + return scanSendQueueEntry(row) +} + +func (a *App) loadLatestSendQueueForMailboxMessage(ctx context.Context, sentMessageID, mailboxID 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 LEFT JOIN messages m ON m.id=sq.sent_message_id + WHERE sq.sent_message_id=? AND sq.mailbox_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, mailboxID) + return scanSendQueueEntry(row) +} + +func (a *App) loadSendQueueEntryForSentMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) { + return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID) +} + +func (a *App) loadPublicAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) { + var messageID string + 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 + WHERE (m.id=? OR m.message_id=?) AND mb.user_id=? AND lower(f.name)='sent' + ORDER BY m.received_at DESC LIMIT 1`, id, id, userID).Scan(&messageID) + if err != nil { + return nil, err + } + return a.messageByID(ctx, messageID, false) +} + +func parsePublicAPILimit(r *http.Request, defaultLimit, maxLimit int) int { + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || limit <= 0 { + return defaultLimit + } + if limit > maxLimit { + return maxLimit + } + return limit +} + +func parsePublicAPIOffset(r *http.Request) int { + cursor := strings.TrimSpace(r.URL.Query().Get("cursor")) + if cursor == "" { + return 0 + } + offset, err := strconv.Atoi(cursor) + if err != nil || offset < 0 { + return 0 + } + return offset +} diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index dfc4fa0..efe1eed 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -37,6 +37,10 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth).Get("/me", a.handleMe) r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile) r.With(a.requireAuth).Post("/me/password", a.handleChangePassword) + r.With(a.requireAuth).Get("/me/api-tokens", a.handleListAPITokens) + r.With(a.requireAuth).Post("/me/api-tokens", a.handleCreateAPIToken) + r.With(a.requireAuth).Post("/me/api-tokens/{id}", a.handleUpdateAPIToken) + r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken) r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions) r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox) r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup) @@ -71,6 +75,23 @@ func (a *App) Router() http.Handler { r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) r.With(a.requireAuth).Get("/events", a.handleEvents) + r.Group(func(r chi.Router) { + 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.requirePermission(PermissionDomainsCreate)).Post("/open/domains", a.handlePublicAPICreateDomain) + r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains/{id}", a.handlePublicAPIGetDomain) + r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/open/domains/{id}", a.handlePublicAPIUpdateDomain) + r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/open/domains/{id}", a.handlePublicAPIDeleteDomain) + r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes", a.handlePublicAPIListMailboxes) + r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/open/mailboxes", a.handlePublicAPICreateMailbox) + r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes/{id}", a.handlePublicAPIGetMailbox) + r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/open/mailboxes/{id}", a.handlePublicAPIUpdateMailbox) + r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/open/mailboxes/{id}", a.handlePublicAPIDeleteMailbox) + r.With(a.requirePermission(PermissionMailSend)).Post("/open/send", a.handlePublicAPISendMail) + r.With(a.requirePermission(PermissionMailRead)).Get("/open/send/{id}", a.handlePublicAPISendStatus) + r.With(a.requirePermission(PermissionMailRead)).Get("/open/mailboxes/{id}/messages", a.handlePublicAPIMailboxMessages) + }) + r.Group(func(r chi.Router) { r.Use(a.requireAuth) r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes) @@ -165,7 +186,7 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") } if r.Method == http.MethodOptions { @@ -187,6 +208,17 @@ func (a *App) requireAuth(next http.Handler) http.Handler { }) } +func (a *App) requireAPIToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := a.authenticateAPIToken(r) + if err != nil { + respondError(w, http.StatusUnauthorized, "api token required") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) + }) +} + func currentUser(r *http.Request) *User { user, _ := r.Context().Value(userContextKey).(*User) return user @@ -218,6 +250,43 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) { return &u, nil } +func (a *App) authenticateAPIToken(r *http.Request) (*User, error) { + token := bearerToken(r) + if token == "" { + return nil, errors.New("no api token") + } + now := a.now().UTC().Format(time.RFC3339Nano) + row := a.db.QueryRowContext(r.Context(), `SELECT at.id,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at + FROM api_tokens at JOIN users u ON u.id=at.user_id + WHERE at.token_hash=? AND at.disabled=0 AND (at.expires_at IS NULL OR at.expires_at > ?)`, hashToken(token), now) + var tokenID string + var u User + var disabled, twoFactorEnabled int + var created string + if err := row.Scan(&tokenID, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { + return nil, err + } + u.Disabled = intBool(disabled) + u.TwoFactorEnabled = intBool(twoFactorEnabled) + u.CreatedAt = parseTime(created) + if u.Disabled { + return nil, errors.New("disabled") + } + if err := a.attachUserAuthorization(r.Context(), &u); err != nil { + return nil, err + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID) + return &u, nil +} + +func bearerToken(r *http.Request) string { + fields := strings.Fields(strings.TrimSpace(r.Header.Get("Authorization"))) + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { + return "" + } + return fields[1] +} + func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) { row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email) var u User diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index bd502e8..e21c4b2 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -23,6 +23,16 @@ type AdminUser struct { Mailboxes []string `json:"mailboxes"` } +type APIToken struct { + ID string `json:"id"` + Name string `json:"name"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Disabled bool `json:"disabled"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type Domain struct { ID string `json:"id"` Name string `json:"name"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 88393ab..1efcb8b 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -51,6 +51,7 @@ export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number export type PermissionGroupSummary = { id: string; name: string } export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string } export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string } +export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; createdAt: string; updatedAt: string } export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 355a42d..8c964b8 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, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, 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, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -39,6 +39,10 @@ export const api = { me: () => request<{ user: User }>("/api/me"), updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }), changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }), + apiTokens: () => request>("/api/me/api-tokens"), + createApiToken: (payload: { name: string; expiresAt?: string }) => request<{ token: string; item: APIToken }>("/api/me/api-tokens", { method: "POST", body: JSON.stringify(payload) }), + updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean }) => request(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }), + deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }), setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }), enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }), disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }), diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index becd8cb..6225950 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels" import { useNavigate, useSearchParams } from "react-router-dom" import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react" import { QRCodeSVG } from "qrcode.react" -import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" +import { api, APIToken, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api" import { cn, formatBytes } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { DisplayMode, useDisplayMode } from "@/lib/display-mode" @@ -32,10 +32,11 @@ import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGrou import { ConfirmDialog } from "@/components/confirm-dialog" import { useToast } from "@/hooks/use-toast" -type Tab = "profile" | "mailboxes" | "clients" | "signatures" | "contacts" | "cleanup" | "rules" | "blocked" | "stats" +type Tab = "profile" | "apiTokens" | "mailboxes" | "clients" | "signatures" | "contacts" | "cleanup" | "rules" | "blocked" | "stats" type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void } const tabs: Record = { profile: { label: "账户资料", icon: }, + apiTokens: { label: "API Token", icon: }, mailboxes: { label: "邮箱管理", icon: }, clients: { label: "第三方客户端", icon: }, signatures: { label: "签名管理", icon: }, @@ -82,6 +83,7 @@ export function ProfilePage() { const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply") const visibleTabKeys = tabKeys.filter((key) => { if (key === "profile") return true + if (key === "apiTokens") return true if (key === "mailboxes") return canAccessMail || canApplyMailbox if (key === "clients") return canAccessMail if (key === "signatures") return canManageSignatures @@ -96,6 +98,7 @@ export function ProfilePage() { const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail }) const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) + const apiTokens = useQuery({ queryKey: ["api-tokens"], queryFn: api.apiTokens }) const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts }) const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures }) const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules }) @@ -144,6 +147,21 @@ export function ProfilePage() { onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) }, onError: (error) => toast({ title: "关闭失败", description: error.message }), }) + const createApiToken = useMutation({ + mutationFn: (payload: { name: string; expiresAt?: string }) => api.createApiToken(payload), + onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已创建" }); return res }, + onError: (error) => toast({ title: "创建失败", description: error.message }), + }) + const updateApiToken = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: { name?: string; expiresAt?: string; disabled?: boolean } }) => api.updateApiToken(id, payload), + onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已更新" }) }, + onError: (error) => toast({ title: "更新失败", description: error.message }), + }) + const deleteApiToken = useMutation({ + mutationFn: api.deleteApiToken, + onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已撤销" }) }, + onError: (error) => toast({ title: "撤销失败", description: error.message }), + }) const createContact = useMutation({ mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) }, @@ -363,6 +381,7 @@ export function ProfilePage() { onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })} /> ) + if (tab === "apiTokens") return createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} /> if (tab === "clients") return if (tab === "signatures") return createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} /> if (tab === "contacts") return createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} /> @@ -886,6 +905,15 @@ function formatDateTime(value: string) { return date.toLocaleString() } +function dateInputValue(date: Date) { + return date.toISOString().slice(0, 10) +} + +function dateInputToISOString(value: string) { + if (!value) return undefined + return new Date(`${value}T23:59:59.999Z`).toISOString() +} + function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) { const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0] const server = clientServerHost(hostname, selected?.address) @@ -973,6 +1001,89 @@ function ClientConfigRow({ label, value, security, onCopy }: { label: string; va ) } +function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelete, onCopy }: { items: APIToken[]; loading: boolean; pending: boolean; onCreate: (payload: { name: string; expiresAt?: string }) => Promise<{ token: string; item: APIToken }>; onUpdate: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean }) => void; onDelete: (id: string) => void; onCopy: (text: string) => void }) { + const [createdToken, setCreatedToken] = React.useState("") + const [pendingConfirm, setPendingConfirm] = React.useState(null) + const defaultExpiresAt = React.useMemo(() => dateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)), []) + + async function submit(event: React.FormEvent) { + event.preventDefault() + const form = new FormData(event.currentTarget) + const expiresAt = dateInputToISOString(String(form.get("expiresAt") || "")) + const res = await onCreate({ name: String(form.get("name") || ""), expiresAt }) + setCreatedToken(res.token) + event.currentTarget.reset() + } + + return ( +
+ + +
+
+ API Token +
用于服务端集成调用 `/api/open`,创建后请立即保存。
+
+ {items.length} 个 +
+
+ + {createdToken && ( +
+
只显示一次
+
+ {createdToken} + +
+
+ )} + +
+ + + + + + + +
+
+
+ + + 已创建的 Token + + {items.map((item) => { + const expired = item.expiresAt ? new Date(item.expiresAt).getTime() <= Date.now() : false + return ( +
+
+
+
{item.name}
+ {item.disabled ? "已禁用" : expired ? "已过期" : "可用"} +
+
+ 创建:{formatDateTime(item.createdAt)} + 过期:{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"} + 最后使用:{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"} +
+
+
+ + +
+
+ ) + })} + {!loading && items.length === 0 && } +
+
+ + { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} /> +
+ ) +} + function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpdate, onSetDefault, onDelete }: { items: MailSignature[]; mailboxes: Mailbox[]; loading: boolean; pending: boolean; onCreate: (form: FormData) => void; onUpdate: (id: string, form: FormData) => void; onSetDefault: (id: string) => void; onDelete: (id: string) => void }) { const [mailboxId, setMailboxId] = React.useState("all") const [isDefault, setIsDefault] = React.useState(false) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f863beb --- /dev/null +++ b/docs/API.md @@ -0,0 +1,278 @@ +# LanQin Email API + +LanQin Email exposes integration-oriented APIs under `/api/open`. + +这些接口用于外部系统集成,统一放在 `/api/open` 下。它们不是匿名公开接口,只接受 API Token,不接受浏览器登录 Session Cookie。 + +## Authentication + +Open API requests must use a Bearer API Token: + +Open API 请求必须使用 Bearer API Token: + +```http +Authorization: Bearer lq_xxx +``` + +Create tokens in **Profile / API Token**. The plain token is shown only once after creation, so store it securely and revoke it if it may have leaked. + +请在 **个人中心 / API Token** 中创建 Token。明文 Token 只会在创建后显示一次,请安全保存;如果怀疑泄露,应立即撤销并重新创建。 + +Created token example: + +创建后的 Token 示例: + +```json +{ + "token": "lq_xxx" +} +``` + +Tokens created without a custom expiration default to 90 days. You can disable or revoke tokens from the same profile page. + +如果没有自定义到期时间,Token 默认 90 天后过期。你可以在同一个个人中心页面中禁用或撤销 Token。 + +## Permissions + +- Domain APIs require admin access and domain permissions. +- Mailbox management APIs require admin access and mailbox permissions. +- Sending mail requires `mail.send`. +- Reading mailbox messages and send status requires `mail.read`. + +- 域名接口需要管理员访问权限和域名相关权限。 +- 邮箱管理接口需要管理员访问权限和邮箱相关权限。 +- 发送邮件需要 `mail.send`。 +- 读取邮箱邮件和发信状态需要 `mail.read`。 + +## Domains + +### List domains + +```http +GET /api/open/domains +Authorization: Bearer lq_xxx +``` + +Response: + +```json +{ + "items": [ + { + "id": "dom_xxx", + "name": "example.com", + "status": "active", + "dkimSelector": "lanqin", + "dkimPublicKey": "...", + "dnsStatus": "unchecked", + "createdAt": "2026-06-29T00:00:00Z" + } + ] +} +``` + +### Create domain + +```http +POST /api/open/domains +Authorization: Bearer lq_xxx +Content-Type: application/json + +{ + "name": "example.com" +} +``` + +### Get domain + +```http +GET /api/open/domains/{id} +Authorization: Bearer lq_xxx +``` + +### Update domain status + +```http +POST /api/open/domains/{id} +Authorization: Bearer lq_xxx +Content-Type: application/json + +{ + "status": "active" +} +``` + +`status` can be `active` or `disabled`. + +### Delete domain + +```http +DELETE /api/open/domains/{id} +Authorization: Bearer lq_xxx +``` + +Domains that still have mailboxes cannot be deleted. + +## Mailboxes + +### List mailboxes + +```http +GET /api/open/mailboxes +Authorization: Bearer lq_xxx +``` + +### Create mailbox + +```http +POST /api/open/mailboxes +Authorization: Bearer lq_xxx +Content-Type: application/json + +{ + "domainId": "dom_xxx", + "localPart": "alice", + "displayName": "Alice", + "password": "Password123!", + "quotaMb": 1024, + "ownerEmail": "alice@example.com" +} +``` + +`ownerEmail` is optional. If omitted, the mailbox address is used as the owner email. If an active user with that email does not exist, LanQin Email creates one. + +也可以传 `userId` 绑定到已有用户。`password` 至少 8 位,并会用于邮箱密码。 + +### Get mailbox + +```http +GET /api/open/mailboxes/{id} +Authorization: Bearer lq_xxx +``` + +### Update mailbox + +```http +POST /api/open/mailboxes/{id} +Authorization: Bearer lq_xxx +Content-Type: application/json + +{ + "displayName": "Alice Work", + "quotaMb": 2048, + "status": "active", + "userId": "usr_xxx" +} +``` + +All fields are optional. `status` can be `active` or `disabled`. + +### Delete mailbox + +```http +DELETE /api/open/mailboxes/{id} +Authorization: Bearer lq_xxx +``` + +## Send Mail + +```http +POST /api/open/send +Authorization: Bearer lq_xxx +Content-Type: application/json + +{ + "mailboxId": "mbx_xxx", + "to": ["bob@example.com"], + "cc": [], + "bcc": [], + "subject": "Hello", + "text": "Plain text body", + "html": "

HTML body

" +} +``` + +Response: + +```json +{ + "id": "mail_xxx", + "queueId": "snd_xxx", + "status": "queued", + "messageId": "mail_xxx", + "rfcMessageId": "", + "mailboxId": "mbx_xxx", + "mailboxAddress": "alice@example.com", + "subject": "Hello", + "createdAt": "2026-06-29T00:00:00Z" +} +``` + +When SMTP delivery is not configured, the message can be stored as accepted without a queue item: + +如果没有配置 SMTP 投递,邮件可能只会进入 `accepted` 状态,不会产生 `queueId`。 + +Current status values: + +- `accepted`: message was accepted and stored, but no SMTP queue item exists. +- `queued`: queued for SMTP delivery. +- `sending`: currently being delivered. +- `delivered`: SMTP delivery succeeded. +- `failed`: delivery failed and may be retried. +- `canceled`: delivery was canceled. + +Bounce, complaint, rejection, and provider-specific delivery events require future webhook or delivery-event integration. + +退信、投诉、拒收等更细状态需要后续接入投递事件或 webhook 后才能完整提供。 + +## Send Status + +```http +GET /api/open/send/{id} +Authorization: Bearer lq_xxx +``` + +`id` can be the value returned by `POST /api/open/send`. If a queue item exists, it can also be the queue id. + +`id` 可以使用发信接口返回的 `id`;如果存在队列项,也可以使用 `queueId`。 + +## Received Messages + +```http +GET /api/open/mailboxes/{id}/messages?folder=Inbox&limit=30&cursor=0&q=keyword +Authorization: Bearer lq_xxx +``` + +Query parameters: + +- `folder`: folder name. Defaults to `Inbox`; use `all` for all folders. +- `limit`: page size, maximum `100`. +- `cursor`: numeric cursor returned as `nextCursor`. +- `q`: optional search keyword. + +Response: + +```json +{ + "items": [ + { + "id": "mail_xxx", + "mailboxId": "mbx_xxx", + "folder": "Inbox", + "messageId": "", + "subject": "Hello", + "from": "sender@example.com", + "to": ["alice@example.com"], + "receivedAt": "2026-06-29T00:00:00Z", + "snippet": "Preview text", + "isRead": false, + "hasAttachments": false + } + ], + "nextCursor": "" +} +``` + +Users can only read messages from their own active mailboxes. + +用户只能读取自己拥有的 active 邮箱。