feat(api): 新增开放 API 与 Token 管理

- 新增 API Token 的创建、查询、更新和撤销接口,并支持 Bearer 认证
- 新增 `/api/open` 域名、邮箱、发信与消息查询接口,补充权限校验
- 更新数据库迁移、路由与测试,并新增开放 API 文档
This commit is contained in:
LanQin_
2026-06-29 15:31:20 +08:00
parent eb8b999ce5
commit f87ce5fa4f
10 changed files with 1619 additions and 4 deletions
+211
View File
@@ -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
}
+13
View File
@@ -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,
+259
View File
@@ -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)
@@ -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
}
+70 -1
View File
@@ -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
+10
View File
@@ -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"`
+1
View File
@@ -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 }
+5 -1
View File
@@ -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<ListResponse<APIToken>>("/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<APIToken>(`/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 }) }),
+113 -2
View File
@@ -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<Tab, { label: string; icon: React.ReactNode }> = {
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
apiTokens: { label: "API Token", icon: <KeyRound className="h-4 w-4" /> },
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
clients: { label: "第三方客户端", icon: <Laptop className="h-4 w-4" /> },
signatures: { label: "签名管理", icon: <KeyRound className="h-4 w-4" /> },
@@ -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 <ApiTokensSection items={apiTokens.data?.items || []} loading={apiTokens.isLoading} pending={createApiToken.isPending || updateApiToken.isPending || deleteApiToken.isPending} onCreate={(payload) => createApiToken.mutateAsync(payload)} onUpdate={(id, payload) => updateApiToken.mutate({ id, payload })} onDelete={(id) => deleteApiToken.mutate(id)} onCopy={copy} />
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => 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<PendingConfirm | null>(null)
const defaultExpiresAt = React.useMemo(() => dateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)), [])
async function submit(event: React.FormEvent<HTMLFormElement>) {
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>API Token</CardTitle>
<div className="mt-1 text-sm text-muted-foreground"> `/api/open`</div>
</div>
<Badge variant="secondary">{items.length} </Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
{createdToken && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-950">
<div className="text-sm font-medium"></div>
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row">
<code className="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-3 py-2 text-xs">{createdToken}</code>
<Button type="button" variant="outline" onClick={() => onCopy(createdToken)}><Copy className="h-4 w-4" /></Button>
</div>
</div>
)}
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end" onSubmit={submit}>
<Field label="名称">
<Input name="name" required maxLength={80} placeholder="billing-system" />
</Field>
<Field label="到期日期">
<Input name="expiresAt" type="date" defaultValue={defaultExpiresAt} min={dateInputValue(new Date())} required />
</Field>
<Button disabled={pending}>{pending ? "创建中..." : "创建 Token"}</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle> Token</CardTitle></CardHeader>
<CardContent className="space-y-3">
{items.map((item) => {
const expired = item.expiresAt ? new Date(item.expiresAt).getTime() <= Date.now() : false
return (
<div key={item.id} className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate font-medium">{item.name}</div>
<Badge variant={item.disabled || expired ? "secondary" : "default"}>{item.disabled ? "已禁用" : expired ? "已过期" : "可用"}</Badge>
</div>
<div className="mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-3">
<span>{formatDateTime(item.createdAt)}</span>
<span>{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"}</span>
<span>使{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={pending || expired} onClick={() => onUpdate(item.id, { disabled: !item.disabled })}>{item.disabled ? "启用" : "禁用"}</Button>
<Button type="button" variant="destructive" size="sm" disabled={pending} onClick={() => setPendingConfirm({ title: "撤销 API Token", description: `Token “${item.name}” 撤销后无法恢复,正在使用它的集成会立即失效。`, confirmText: "撤销 Token", destructive: true, onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}></Button>
</div>
</div>
)
})}
{!loading && items.length === 0 && <EmptyState text="暂无 API Token" />}
</CardContent>
</Card>
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "撤销"} destructive={!!pendingConfirm?.destructive} pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
</div>
)
}
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)
+278
View File
@@ -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": "<p>HTML body</p>"
}
```
Response:
```json
{
"id": "mail_xxx",
"queueId": "snd_xxx",
"status": "queued",
"messageId": "mail_xxx",
"rfcMessageId": "<msg_xxx@example.com>",
"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": "<message@example.com>",
"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 邮箱。