feat(api): 升级开放 API 并支持投递回调

- 为 `/api/open/v1` 引入 Token scope、分页游标、幂等发信、发送事件与重试/取消能力。
- 新增投递事件签名回调与状态 webhook outbox,补充相关配置、迁移和测试。
- 同步更新 Web 端 API 类型、个人中心 Token 权限管理,以及中英文文档和 OpenAPI 契约。
This commit is contained in:
LanQin_
2026-07-10 10:47:58 +08:00
parent 25f54bc42f
commit 47f782a03c
20 changed files with 1891 additions and 162 deletions
+4
View File
@@ -190,6 +190,10 @@ Mail flow:
4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`. 4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`.
5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables. 5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables.
## Open API
External integrations should use the versioned `/api/open/v1` endpoints with scoped API Tokens. See the [API guide](docs/API.md) and the machine-readable [OpenAPI 3.1 contract](docs/openapi.json). Sending supports idempotency keys; final delivery events can be ingested through a signed endpoint and all status changes can be pushed through the reliable signed webhook outbox.
## Development and Verification ## Development and Verification
```bash ```bash
+4
View File
@@ -190,6 +190,10 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。 4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。 5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开放 API
外部系统应使用版本化的 `/api/open/v1` 接口和带 scope 的 API Token。详细说明见 [API 文档](docs/API.md),机器可读契约见 [OpenAPI 3.1](docs/openapi.json)。发信支持幂等键;最终投递事件可通过签名入口写入,全部状态变化也可通过可靠的签名 webhook outbox 主动推送。
## 开发与验证 ## 开发与验证
```bash ```bash
+2 -2
View File
@@ -964,7 +964,7 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
source = normalizeLocalPart(source) + "@" + domain.Name source = normalizeLocalPart(source) + "@" + domain.Name
} }
destination := normalizeEmail(req.Destination) destination := normalizeEmail(req.Destination)
if source == "" || destination == "" || !strings.Contains(destination, "@") { if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
badRequest(w, errors.New("invalid alias")) badRequest(w, errors.New("invalid alias"))
return return
} }
@@ -1009,7 +1009,7 @@ func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
source = normalizeLocalPart(source) + "@" + domain.Name source = normalizeLocalPart(source) + "@" + domain.Name
} }
destination := normalizeEmail(req.Destination) destination := normalizeEmail(req.Destination)
if source == "" || destination == "" || !strings.Contains(destination, "@") { if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
badRequest(w, errors.New("invalid alias")) badRequest(w, errors.New("invalid alias"))
return return
} }
+80 -13
View File
@@ -3,7 +3,9 @@ package app
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -13,9 +15,24 @@ import (
const defaultAPITokenTTL = 90 * 24 * time.Hour const defaultAPITokenTTL = 90 * 24 * time.Hour
var validAPITokenScopes = map[string]bool{
"*": true,
"domains:read": true,
"domains:write": true,
"mailboxes:read": true,
"mailboxes:write": true,
"messages:read": true,
"messages:send": true,
"messages:manage": true,
"aliases:read": true,
"aliases:write": true,
"dns:read": true,
"dns:check": true,
}
func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) { func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,created_at,updated_at rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID) FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list api tokens") respondError(w, http.StatusInternalServerError, "failed to list api tokens")
@@ -41,8 +58,9 @@ func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
ExpiresAt string `json:"expiresAt"` ExpiresAt string `json:"expiresAt"`
Scopes json.RawMessage `json:"scopes"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
@@ -66,6 +84,20 @@ func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL) defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
expiresAt = &defaultExpiry expiresAt = &defaultExpiry
} }
var requestedScopes []string
if len(req.Scopes) > 0 {
if string(req.Scopes) == "null" || json.Unmarshal(req.Scopes, &requestedScopes) != nil {
badRequest(w, errors.New("scopes must be an array of strings"))
return
}
} else {
requestedScopes = nil
}
scopes, err := normalizeAPITokenScopes(requestedScopes)
if err != nil {
badRequest(w, err)
return
}
id := newID("apt") id := newID("apt")
token := "lq_" + randomToken() token := "lq_" + randomToken()
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
@@ -73,8 +105,8 @@ func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
if expiresAt != nil { if expiresAt != nil {
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano) 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) if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,scopes_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, jsonEncode(scopes), now, now); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create api token") respondError(w, http.StatusInternalServerError, "failed to create api token")
return return
} }
@@ -94,9 +126,10 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
Name *string `json:"name"` Name *string `json:"name"`
ExpiresAt *string `json:"expiresAt"` ExpiresAt *string `json:"expiresAt"`
Disabled *bool `json:"disabled"` Disabled *bool `json:"disabled"`
Scopes *[]string `json:"scopes"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
@@ -142,8 +175,16 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
if req.Disabled != nil { if req.Disabled != nil {
disabled = *req.Disabled 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=?`, scopes := current.Scopes
name, expiresValue, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id, user.ID) if req.Scopes != nil {
scopes, err = normalizeAPITokenScopes(*req.Scopes)
if err != nil {
badRequest(w, err)
return
}
}
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,scopes_json=?,updated_at=? WHERE id=? AND user_id=?`,
name, expiresValue, boolInt(disabled), jsonEncode(scopes), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update api token") respondError(w, http.StatusInternalServerError, "failed to update api token")
return return
@@ -175,7 +216,7 @@ func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) { 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 row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
FROM api_tokens WHERE id=? AND user_id=?`, id, userID) FROM api_tokens WHERE id=? AND user_id=?`, id, userID)
return scanAPIToken(row) return scanAPIToken(row)
} }
@@ -186,18 +227,44 @@ func scanAPIToken(row apiTokenScanner) (APIToken, error) {
var item APIToken var item APIToken
var lastUsed, expires sql.NullString var lastUsed, expires sql.NullString
var disabled int var disabled int
var created, updated string var scopesJSON, created, updated string
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &created, &updated); err != nil { if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &scopesJSON, &created, &updated); err != nil {
return item, err return item, err
} }
item.LastUsedAt = nullableTime(lastUsed) item.LastUsedAt = nullableTime(lastUsed)
item.ExpiresAt = nullableTime(expires) item.ExpiresAt = nullableTime(expires)
item.Disabled = intBool(disabled) item.Disabled = intBool(disabled)
item.Scopes = jsonDecodeSlice(scopesJSON)
item.CreatedAt = parseTime(created) item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated) item.UpdatedAt = parseTime(updated)
return item, nil return item, nil
} }
func normalizeAPITokenScopes(scopes []string) ([]string, error) {
if scopes == nil {
return []string{"*"}, nil
}
if len(scopes) == 0 {
return nil, errors.New("at least one api token scope is required")
}
seen := map[string]bool{}
out := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.ToLower(strings.TrimSpace(scope))
if !validAPITokenScopes[scope] {
return nil, fmt.Errorf("invalid api token scope: %s", scope)
}
if !seen[scope] {
seen[scope] = true
out = append(out, scope)
}
}
if seen["*"] && len(out) != 1 {
return nil, errors.New("wildcard scope cannot be combined with other scopes")
}
return out, nil
}
func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) { func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
+102 -11
View File
@@ -82,6 +82,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
a.startWorker(func() { a.sendQueueWorker(workerCtx) }) a.startWorker(func() { a.sendQueueWorker(workerCtx) })
a.startWorker(func() { a.externalIMAPWorker(workerCtx) }) a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) }) a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
return a, nil return a, nil
} }
@@ -171,9 +172,20 @@ func (a *App) migrate(ctx context.Context) error {
last_used_at TEXT, last_used_at TEXT,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
scopes_json TEXT NOT NULL DEFAULT '["*"]',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
)`, )`,
`CREATE TABLE IF NOT EXISTS send_idempotency_keys (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
sent_message_id TEXT NOT NULL DEFAULT '',
queue_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, idempotency_key)
)`,
`CREATE INDEX IF NOT EXISTS idx_send_idempotency_created ON send_idempotency_keys(created_at)`,
`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_user ON api_tokens(user_id, created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash)`, `CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash)`,
`CREATE TABLE IF NOT EXISTS system_settings ( `CREATE TABLE IF NOT EXISTS system_settings (
@@ -327,6 +339,45 @@ func (a *App) migrate(ctx context.Context) error {
created_at TEXT NOT NULL created_at TEXT NOT NULL
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`, `CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS delivery_events (
id TEXT PRIMARY KEY,
external_id TEXT NOT NULL,
provider TEXT NOT NULL,
queue_id TEXT NOT NULL DEFAULT '',
sent_message_id TEXT NOT NULL DEFAULT '',
rfc_message_id TEXT NOT NULL DEFAULT '',
recipient TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
occurred_at TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(provider, external_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_delivery_events_message ON delivery_events(sent_message_id, occurred_at, id)`,
`CREATE INDEX IF NOT EXISTS idx_delivery_events_rfc_message ON delivery_events(rfc_message_id, occurred_at, id)`,
`CREATE TABLE IF NOT EXISTS status_webhook_outbox (
id TEXT PRIMARY KEY,
event_key TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
mailbox_id TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
delivered_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
AFTER DELETE ON mailboxes BEGIN
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
END`,
`CREATE TRIGGER IF NOT EXISTS trg_send_queue_delete_delivery_events
AFTER DELETE ON send_queue BEGIN
DELETE FROM delivery_events WHERE queue_id=OLD.id;
END`,
`CREATE TABLE IF NOT EXISTS attachments ( `CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -546,12 +597,45 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateExternalIMAP(ctx); err != nil { if err := a.migrateExternalIMAP(ctx); err != nil {
return err return err
} }
if err := a.migrateAPITokenScopes(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil { if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err return err
} }
return nil return nil
} }
func (a *App) migrateAPITokenScopes(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`)
if err != nil {
return err
}
hasScopes := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "scopes_json" {
hasScopes = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasScopes {
return nil
}
_, err = a.db.ExecContext(ctx, `ALTER TABLE api_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '["*"]'`)
return err
}
func (a *App) migrateMessageAuthentication(ctx context.Context) error { func (a *App) migrateMessageAuthentication(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`) rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil { if err != nil {
@@ -1196,6 +1280,22 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
} }
func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) { func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) {
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
id, err := a.createMailboxWithPasswordHashTx(ctx, tx, userID, domainID, localPart, displayName, passwordHash, quotaMB, status)
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return id, nil
}
func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) {
localPart = normalizeLocalPart(localPart) localPart = normalizeLocalPart(localPart)
if localPart == "" { if localPart == "" {
return "", errors.New("invalid local part") return "", errors.New("invalid local part")
@@ -1207,7 +1307,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
status = "active" status = "active"
} }
var domain string var domain string
if err := a.db.QueryRowContext(ctx, `SELECT name FROM domains WHERE id=?`, domainID).Scan(&domain); err != nil { if err := tx.QueryRowContext(ctx, `SELECT name FROM domains WHERE id=?`, domainID).Scan(&domain); err != nil {
return "", err return "", err
} }
address := localPart + "@" + domain address := localPart + "@" + domain
@@ -1215,15 +1315,9 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
displayName = address displayName = address
} }
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
id := newID("mbx") id := newID("mbx")
now := a.now().UTC().Format(time.RFC3339Nano) now := a.now().UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at) _, err := tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, passwordHash, quotaMB, status, now, now) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, passwordHash, quotaMB, status, now, now)
if err != nil { if err != nil {
return "", err return "", err
@@ -1234,9 +1328,6 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
return "", err return "", err
} }
} }
if err := tx.Commit(); err != nil {
return "", err
}
return id, nil return id, nil
} }
+358 -2
View File
@@ -4,12 +4,16 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"database/sql"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors" "errors"
@@ -24,6 +28,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -222,6 +227,10 @@ type testClient struct {
} }
func (c *testClient) do(method, path string, body any, out any) int { func (c *testClient) do(method, path string, body any, out any) int {
return c.doWithHeaders(method, path, body, nil, out)
}
func (c *testClient) doWithHeaders(method, path string, body any, headers map[string]string, out any) int {
c.t.Helper() c.t.Helper()
var reader io.Reader var reader io.Reader
if body != nil { if body != nil {
@@ -241,6 +250,9 @@ func (c *testClient) do(method, path string, body any, out any) int {
if c.bearer != "" { if c.bearer != "" {
req.Header.Set("Authorization", "Bearer "+c.bearer) req.Header.Set("Authorization", "Bearer "+c.bearer)
} }
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
c.t.Fatal(err) c.t.Fatal(err)
@@ -284,12 +296,20 @@ func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, dis
} }
func createTestAPIToken(t *testing.T, client *testClient, name string) string { func createTestAPIToken(t *testing.T, client *testClient, name string) string {
return createTestAPITokenWithScopes(t, client, name, nil)
}
func createTestAPITokenWithScopes(t *testing.T, client *testClient, name string, scopes []string) string {
t.Helper() t.Helper()
var resp struct { var resp struct {
Token string `json:"token"` Token string `json:"token"`
Item APIToken `json:"item"` Item APIToken `json:"item"`
} }
if code := client.do("POST", "/api/me/api-tokens", map[string]string{"name": name}, &resp); code != http.StatusCreated { payload := map[string]any{"name": name}
if scopes != nil {
payload["scopes"] = scopes
}
if code := client.do("POST", "/api/me/api-tokens", payload, &resp); code != http.StatusCreated {
t.Fatalf("create api token code=%d resp=%+v", code, resp) t.Fatalf("create api token code=%d resp=%+v", code, resp)
} }
if resp.Token == "" || resp.Item.ID == "" || resp.Item.Name != name { if resp.Token == "" || resp.Item.ID == "" || resp.Item.Name != name {
@@ -1684,6 +1704,21 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) {
if code := admin.do("POST", "/api/me/api-tokens", map[string]string{"name": "integration-test"}, &created); code != http.StatusCreated { 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) t.Fatalf("create api token code=%d resp=%+v", code, created)
} }
nullScopes := bytes.NewBufferString(`{"name":"null-scopes","scopes":null}`)
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/me/api-tokens", nullScopes)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.AddCookie(admin.cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("null scopes create code=%d", resp.StatusCode)
}
if !strings.HasPrefix(created.Token, "lq_") || created.Item.ID == "" || created.Item.Name != "integration-test" || created.Item.ExpiresAt == nil { 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) t.Fatalf("created token response=%+v", created)
} }
@@ -1908,7 +1943,7 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
if code := senderOpen.do("GET", "/api/open/send/"+sent.ID, nil, &status); code != http.StatusOK { if code := senderOpen.do("GET", "/api/open/send/"+sent.ID, nil, &status); code != http.StatusOK {
t.Fatalf("open api send status code=%d status=%+v", code, status) t.Fatalf("open api send status code=%d status=%+v", code, status)
} }
if status.ID != sent.QueueID || status.MessageID != sent.MessageID || status.Status != sendQueueStatusQueued { if status.ID != sent.MessageID || status.QueueID != sent.QueueID || status.MessageID != sent.MessageID || status.Status != sendQueueStatusQueued {
t.Fatalf("status=%+v sent=%+v", status, sent) t.Fatalf("status=%+v sent=%+v", status, sent)
} }
@@ -1936,6 +1971,327 @@ func TestOpenAPISendStatusAndMailboxMessages(t *testing.T) {
} }
} }
func TestOpenAPIV1ScopesIdempotencyAndDeliveryEvents(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
a.cfg.DeliveryWebhookSecret = "delivery-test-secret"
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "v1-sender", "V1 Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "v1-recipient", "V1 Recipient", "Password123!", nil)
adminReadToken := createTestAPITokenWithScopes(t, admin, "domain-reader", []string{"domains:read"})
adminRead := &testClient{t: t, server: ts, bearer: adminReadToken}
if code := adminRead.do("GET", "/api/open/v1/domains", nil, &map[string]any{}); code != http.StatusOK {
t.Fatalf("v1 scoped domain list code=%d", code)
}
if code := adminRead.do("POST", "/api/open/v1/domains", map[string]string{"name": "scope-denied.example"}, &map[string]any{}); code != http.StatusForbidden {
t.Fatalf("read-only token domain create code=%d", code)
}
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", code)
}
sendToken := createTestAPITokenWithScopes(t, senderClient, "send-only", []string{"messages:send"})
sendClient := &testClient{t: t, server: ts, bearer: sendToken}
payload := map[string]any{"mailboxId": sender.ID, "to": []string{recipient.Address}, "subject": "idempotent send", "text": "one delivery"}
headers := map[string]string{"Idempotency-Key": "invoice-42"}
var first openAPISendStatus
if code := sendClient.doWithHeaders("POST", "/api/open/v1/send", payload, headers, &first); code != http.StatusCreated {
t.Fatalf("first idempotent send code=%d body=%+v", code, first)
}
if first.ID == "" || first.ID != first.MessageID || first.QueueID == "" || first.ID == first.QueueID {
t.Fatalf("stable send identifiers=%+v", first)
}
var replay openAPISendStatus
if code := sendClient.doWithHeaders("POST", "/api/open/v1/send", payload, headers, &replay); code != http.StatusOK {
t.Fatalf("idempotent replay code=%d body=%+v", code, replay)
}
if replay.ID != first.ID || replay.QueueID != first.QueueID {
t.Fatalf("replay=%+v first=%+v", replay, first)
}
var queueCount int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM send_queue WHERE source=? AND sent_message_id=?`, sendSourceOpenAPI, first.MessageID).Scan(&queueCount); err != nil || queueCount != 1 {
t.Fatalf("idempotent queue count=%d err=%v", queueCount, err)
}
changed := map[string]any{"mailboxId": sender.ID, "to": []string{recipient.Address}, "subject": "changed", "text": "different"}
if code := sendClient.doWithHeaders("POST", "/api/open/v1/send", changed, headers, &map[string]any{}); code != http.StatusConflict {
t.Fatalf("changed idempotency payload code=%d", code)
}
if code := sendClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &map[string]any{}); code != http.StatusForbidden {
t.Fatalf("send-only token read code=%d", code)
}
readToken := createTestAPITokenWithScopes(t, senderClient, "read-only", []string{"messages:read"})
readClient := &testClient{t: t, server: ts, bearer: readToken}
var queued openAPISendStatus
if code := readClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &queued); code != http.StatusOK || queued.Status != sendQueueStatusQueued {
t.Fatalf("read status code=%d body=%+v", code, queued)
}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,updated_at=? WHERE id=?`, sendQueueStatusSending, a.now().UTC().Format(time.RFC3339Nano), first.QueueID); err != nil {
t.Fatal(err)
}
var sending openAPISendStatus
if code := readClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &sending); code != http.StatusOK || sending.Status != sendQueueStatusSending {
t.Fatalf("sending status code=%d body=%+v", code, sending)
}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,delivered_at=?,updated_at=? WHERE id=?`, sendQueueStatusDelivered, a.now().UTC().Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano), first.QueueID); err != nil {
t.Fatal(err)
}
var relayed openAPISendStatus
if code := readClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &relayed); code != http.StatusOK || relayed.Status != "relayed" || relayed.QueueStatus != sendQueueStatusDelivered {
t.Fatalf("relayed status code=%d body=%+v", code, relayed)
}
eventPayload := struct {
Events []deliveryWebhookEvent `json:"events"`
}{Events: []deliveryWebhookEvent{{ID: "provider-event-1", Provider: "test-provider", MessageID: first.MessageID, Recipient: recipient.Address, Status: "bounced", Reason: "550 mailbox unavailable", OccurredAt: a.now().UTC().Format(time.RFC3339Nano)}}}
body, _ := json.Marshal(eventPayload)
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
webhookHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(mac.Sum(nil))}
badSignatureHeaders := map[string]string{"X-LanQin-Timestamp": timestamp, "X-LanQin-Signature": "sha256=" + strings.Repeat("0", 64)}
if code := admin.doWithHeaders("POST", "/api/open/v1/delivery-events", eventPayload, badSignatureHeaders, &map[string]any{}); code != http.StatusUnauthorized {
t.Fatalf("invalid delivery webhook signature code=%d", code)
}
oldTimestamp := strconv.FormatInt(a.now().UTC().Add(-10*time.Minute).Unix(), 10)
oldMAC := hmac.New(sha256.New, []byte(a.cfg.DeliveryWebhookSecret))
_, _ = oldMAC.Write([]byte(oldTimestamp + "."))
_, _ = oldMAC.Write(body)
oldHeaders := map[string]string{"X-LanQin-Timestamp": oldTimestamp, "X-LanQin-Signature": "sha256=" + hex.EncodeToString(oldMAC.Sum(nil))}
if code := admin.doWithHeaders("POST", "/api/open/v1/delivery-events", eventPayload, oldHeaders, &map[string]any{}); code != http.StatusUnauthorized {
t.Fatalf("expired delivery webhook signature code=%d", code)
}
var webhookResult struct {
Accepted int `json:"accepted"`
Duplicates int `json:"duplicates"`
}
if code := admin.doWithHeaders("POST", "/api/open/v1/delivery-events", eventPayload, webhookHeaders, &webhookResult); code != http.StatusOK || webhookResult.Accepted != 1 {
t.Fatalf("delivery webhook code=%d body=%+v", code, webhookResult)
}
if code := admin.doWithHeaders("POST", "/api/open/v1/delivery-events", eventPayload, webhookHeaders, &webhookResult); code != http.StatusOK || webhookResult.Duplicates != 1 {
t.Fatalf("delivery webhook duplicate code=%d body=%+v", code, webhookResult)
}
var bounced openAPISendStatus
if code := readClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &bounced); code != http.StatusOK || bounced.Status != "bounced" || len(bounced.RecipientStatuses) != 1 {
t.Fatalf("bounced status code=%d body=%+v", code, bounced)
}
var events struct {
DeliveryEvents []DeliveryEvent `json:"deliveryEvents"`
}
if code := readClient.do("GET", "/api/open/v1/send/"+first.ID+"/events", nil, &events); code != http.StatusOK || len(events.DeliveryEvents) != 1 {
t.Fatalf("delivery events code=%d body=%+v", code, events)
}
manageToken := createTestAPITokenWithScopes(t, senderClient, "send-manager", []string{"messages:read", "messages:manage"})
manageClient := &testClient{t: t, server: ts, bearer: manageToken}
if _, err := a.db.Exec(`UPDATE send_queue SET status=?,attempt_count=max_attempts,last_error='test failure',updated_at=? WHERE id=?`, sendQueueStatusFailed, a.now().UTC().Format(time.RFC3339Nano), first.QueueID); err != nil {
t.Fatal(err)
}
var failed openAPISendStatus
if code := manageClient.do("GET", "/api/open/v1/send/"+first.ID, nil, &failed); code != http.StatusOK || failed.QueueStatus != sendQueueStatusFailed {
t.Fatalf("failed status code=%d body=%+v", code, failed)
}
var retried openAPISendStatus
if code := manageClient.do("POST", "/api/open/v1/send/"+first.ID+"/retry", nil, &retried); code != http.StatusOK || retried.QueueStatus != sendQueueStatusQueued {
t.Fatalf("retry code=%d body=%+v", code, retried)
}
var canceled openAPISendStatus
if code := manageClient.do("POST", "/api/open/v1/send/"+first.ID+"/cancel", nil, &canceled); code != http.StatusOK || canceled.QueueStatus != sendQueueStatusCanceled {
t.Fatalf("cancel code=%d body=%+v", code, canceled)
}
}
func TestOpenAPIPaginationAndMailboxCreateRollback(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", code)
}
token := createTestAPITokenWithScopes(t, admin, "admin-v1", []string{"domains:read", "mailboxes:write"})
openAdmin := &testClient{t: t, server: ts, bearer: token}
createTestDomain(t, admin, "pagination-one.test")
createTestDomain(t, admin, "pagination-two.test")
var firstPage struct {
Items []Domain `json:"items"`
NextCursor string `json:"nextCursor"`
}
if code := openAdmin.do("GET", "/api/open/v1/domains?limit=1", nil, &firstPage); code != http.StatusOK || len(firstPage.Items) != 1 || firstPage.NextCursor == "" {
t.Fatalf("first domain page code=%d body=%+v", code, firstPage)
}
var secondPage struct {
Items []Domain `json:"items"`
}
if code := openAdmin.do("GET", "/api/open/v1/domains?limit=1&cursor="+url.QueryEscape(firstPage.NextCursor), nil, &secondPage); code != http.StatusOK || len(secondPage.Items) != 1 || secondPage.Items[0].ID == firstPage.Items[0].ID {
t.Fatalf("second domain page code=%d body=%+v", code, secondPage)
}
domainID := mustDefaultDomainID(t, a)
createTestMailbox(t, admin, domainID, "rollback-address", "Existing", "Password123!", nil)
payload := map[string]any{"domainId": domainID, "localPart": "rollback-address", "displayName": "Should Rollback", "password": "Password123!", "ownerEmail": "orphan-owner@example.test"}
if code := openAdmin.do("POST", "/api/open/v1/mailboxes", payload, &map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("duplicate mailbox create code=%d", code)
}
var orphanCount int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM users WHERE email=?`, "orphan-owner@example.test").Scan(&orphanCount); err != nil || orphanCount != 0 {
t.Fatalf("orphan user count=%d err=%v", orphanCount, err)
}
}
func TestOpenAPIContractCoversV1Routes(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "docs", "openapi.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var document struct {
OpenAPI string `json:"openapi"`
Paths map[string]map[string]json.RawMessage `json:"paths"`
Components struct {
SecuritySchemes map[string]json.RawMessage `json:"securitySchemes"`
} `json:"components"`
}
if err := json.Unmarshal(data, &document); err != nil {
t.Fatalf("parse openapi contract: %v", err)
}
if document.OpenAPI != "3.1.0" || document.Components.SecuritySchemes["bearerAuth"] == nil {
t.Fatalf("invalid openapi metadata: version=%q security=%v", document.OpenAPI, document.Components.SecuritySchemes)
}
routes := map[string][]string{
"/domains": {"get", "post"}, "/domains/{id}": {"get", "post", "delete"},
"/domains/{id}/dns-records": {"get"}, "/domains/{id}/dns-check": {"post"},
"/mailboxes": {"get", "post"}, "/mailboxes/{id}": {"get", "post", "delete"},
"/mailboxes/{id}/password": {"post"}, "/mailboxes/{id}/messages": {"get"},
"/messages/{id}": {"get"}, "/attachments/{id}": {"get"},
"/send": {"get", "post"}, "/send/{id}": {"get"}, "/send/{id}/events": {"get"},
"/send/{id}/retry": {"post"}, "/send/{id}/cancel": {"post"},
"/aliases": {"get", "post"}, "/aliases/{id}": {"get", "post", "delete"},
"/delivery-events": {"post"},
}
for route, methods := range routes {
pathItem := document.Paths[route]
if pathItem == nil {
t.Errorf("openapi missing path %s", route)
continue
}
for _, method := range methods {
if pathItem[method] == nil {
t.Errorf("openapi missing operation %s %s", strings.ToUpper(method), route)
}
}
if strings.Contains(route, "{id}") {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
paths, _ := raw["paths"].(map[string]any)
item, _ := paths[route].(map[string]any)
parameters, _ := item["parameters"].([]any)
foundID := false
for _, value := range parameters {
parameter, _ := value.(map[string]any)
if parameter["$ref"] == "#/components/parameters/ResourceId" || parameter["name"] == "id" {
foundID = true
}
}
if !foundID {
t.Errorf("openapi path %s does not declare id parameter", route)
}
}
}
}
func TestStatusWebhookOutboxDeliveryRetryAndSSRFProtection(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
accept := false
requests := 0
receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
timestamp := r.Header.Get("X-LanQin-Timestamp")
mac := hmac.New(sha256.New, []byte("outbound-test-secret"))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
if r.Header.Get("X-LanQin-Webhook-Id") == "" || r.Header.Get("X-LanQin-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
t.Error("invalid outbound webhook signature headers")
}
var envelope statusWebhookEnvelope
if err := json.Unmarshal(body, &envelope); err != nil || envelope.Type != "send.failed" {
t.Errorf("invalid outbound webhook payload: err=%v payload=%s", err, body)
}
if !accept {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer receiver.Close()
a.cfg.StatusWebhookURL = receiver.URL
a.cfg.StatusWebhookSecret = "outbound-test-secret"
a.cfg.StatusWebhookAllowPrivateHosts = true
user, mb := defaultAdminUserAndMailbox(t, a)
a.recordSendAudit(context.Background(), sendAuditFailed, sendQueueStatusFailed, sendAuditInput{QueueID: "snd_test", UserID: user.ID, MailboxID: mb.ID, SentMessageID: "mail_test", Source: sendSourceOpenAPI, MailFrom: mb.Address, Recipients: []string{"recipient@example.test"}, Error: "test failure"})
var outboxID string
if err := a.db.QueryRow(`SELECT id FROM status_webhook_outbox WHERE event_type='send.failed'`).Scan(&outboxID); err != nil {
t.Fatal(err)
}
if err := a.processDueStatusWebhooks(context.Background()); err != nil {
t.Fatal(err)
}
var attempts int
var deliveredAt sql.NullString
if err := a.db.QueryRow(`SELECT attempt_count,delivered_at FROM status_webhook_outbox WHERE id=?`, outboxID).Scan(&attempts, &deliveredAt); err != nil || attempts != 1 || deliveredAt.Valid {
t.Fatalf("failed delivery outbox attempts=%d delivered=%v err=%v", attempts, deliveredAt, err)
}
accept = true
if _, err := a.db.Exec(`UPDATE status_webhook_outbox SET next_attempt_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), outboxID); err != nil {
t.Fatal(err)
}
if err := a.processDueStatusWebhooks(context.Background()); err != nil {
t.Fatal(err)
}
if err := a.db.QueryRow(`SELECT attempt_count,delivered_at FROM status_webhook_outbox WHERE id=?`, outboxID).Scan(&attempts, &deliveredAt); err != nil || attempts != 2 || !deliveredAt.Valid || requests != 2 {
t.Fatalf("successful retry attempts=%d delivered=%v requests=%d err=%v", attempts, deliveredAt, requests, err)
}
if _, err := a.db.Exec(`DELETE FROM mailboxes WHERE id=?`, mb.ID); err != nil {
t.Fatal(err)
}
var remaining int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM status_webhook_outbox WHERE id=?`, outboxID).Scan(&remaining); err != nil || remaining != 0 {
t.Fatalf("mailbox deletion should remove webhook outbox, remaining=%d err=%v", remaining, err)
}
privateTLS := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer privateTLS.Close()
a.cfg.StatusWebhookURL = privateTLS.URL
a.cfg.StatusWebhookAllowPrivateHosts = false
if _, err := a.validatedStatusWebhookURL(context.Background()); err == nil || !strings.Contains(err.Error(), "private or local") {
t.Fatalf("private webhook target should be rejected, err=%v", err)
}
}
func TestSendQueueRecoversStaleSendingItems(t *testing.T) { func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
stopTestWorkers(a) stopTestWorkers(a)
+8
View File
@@ -51,6 +51,10 @@ type Config struct {
ExternalIMAPOutlookClientSecret string ExternalIMAPOutlookClientSecret string
MailTranslateEnabled bool MailTranslateEnabled bool
MailTranslateMaxChars int MailTranslateMaxChars int
DeliveryWebhookSecret string
StatusWebhookURL string
StatusWebhookSecret string
StatusWebhookAllowPrivateHosts bool
} }
func LoadConfig() Config { func LoadConfig() Config {
@@ -99,6 +103,10 @@ func LoadConfig() Config {
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""), ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true), MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000), MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""),
StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""),
StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false),
} }
} }
+414
View File
@@ -0,0 +1,414 @@
package app
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const deliveryWebhookMaxAge = 5 * time.Minute
type deliveryWebhookEvent struct {
ID string `json:"id"`
Provider string `json:"provider"`
QueueID string `json:"queueId"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason"`
OccurredAt string `json:"occurredAt"`
}
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
if secret == "" {
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
return
}
timestamp := strings.TrimSpace(r.Header.Get("X-LanQin-Timestamp"))
signature := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("X-LanQin-Signature")), "sha256=")
unix, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || signature == "" {
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
return
}
signedAt := time.Unix(unix, 0)
if delta := a.now().UTC().Sub(signedAt); delta < -deliveryWebhookMaxAge || delta > deliveryWebhookMaxAge {
respondError(w, http.StatusUnauthorized, "webhook timestamp is outside the allowed window")
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
badRequest(w, errors.New("invalid webhook body"))
return
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(body)
expected, err := hex.DecodeString(signature)
if err != nil || !hmac.Equal(mac.Sum(nil), expected) {
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
return
}
var payload struct {
Events []deliveryWebhookEvent `json:"events"`
}
dec := json.NewDecoder(strings.NewReader(string(body)))
dec.DisallowUnknownFields()
if err := dec.Decode(&payload); err != nil || len(payload.Events) == 0 || len(payload.Events) > 100 {
badRequest(w, errors.New("events must contain between 1 and 100 items"))
return
}
accepted := 0
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start delivery event transaction")
return
}
defer tx.Rollback()
for _, event := range payload.Events {
inserted, err := a.storeDeliveryEvent(r, tx, event)
if err != nil {
badRequest(w, err)
return
}
if inserted {
accepted++
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to store delivery events")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "accepted": accepted, "duplicates": len(payload.Events) - accepted})
}
func (a *App) storeDeliveryEvent(r *http.Request, tx *sql.Tx, event deliveryWebhookEvent) (bool, error) {
event.ID = strings.TrimSpace(event.ID)
event.Provider = strings.ToLower(strings.TrimSpace(event.Provider))
event.QueueID = strings.TrimSpace(event.QueueID)
event.MessageID = strings.TrimSpace(event.MessageID)
event.RFCMessageID = strings.TrimSpace(event.RFCMessageID)
event.Recipient = normalizeEmail(event.Recipient)
event.Status = strings.ToLower(strings.TrimSpace(event.Status))
if event.ID == "" || len(event.ID) > 200 || event.Provider == "" || len(event.Provider) > 80 || event.Recipient == "" || len(event.Recipient) > 320 || len(event.Reason) > 2000 || !validDeliveryEventStatus(event.Status) {
return false, errors.New("invalid delivery event")
}
if event.QueueID == "" && event.MessageID == "" && event.RFCMessageID == "" {
return false, errors.New("queueId, messageId, or rfcMessageId is required")
}
occurredAt, err := time.Parse(time.RFC3339Nano, event.OccurredAt)
if err != nil {
return false, errors.New("occurredAt must be an RFC3339 timestamp")
}
var queueID, sentMessageID, rfcMessageID string
err = tx.QueryRowContext(r.Context(), `SELECT id,sent_message_id,message_id FROM send_queue
WHERE (?<>'' AND id=?) OR (?<>'' AND sent_message_id=?) OR (?<>'' AND message_id=?)
ORDER BY created_at DESC LIMIT 1`, event.QueueID, event.QueueID, event.MessageID, event.MessageID, event.RFCMessageID, event.RFCMessageID).Scan(&queueID, &sentMessageID, &rfcMessageID)
if err != nil {
return false, errors.New("send item not found")
}
if (event.QueueID != "" && event.QueueID != queueID) || (event.MessageID != "" && event.MessageID != sentMessageID) || (event.RFCMessageID != "" && event.RFCMessageID != rfcMessageID) {
return false, errors.New("delivery event identifiers do not refer to the same send item")
}
var recipientsJSON string
if err := tx.QueryRowContext(r.Context(), `SELECT recipients_json FROM send_queue WHERE id=?`, queueID).Scan(&recipientsJSON); err != nil {
return false, errors.New("send item not found")
}
foundRecipient := false
for _, recipient := range jsonDecodeSlice(recipientsJSON) {
if normalizeEmail(recipient) == event.Recipient {
foundRecipient = true
break
}
}
if !foundRecipient {
return false, errors.New("delivery event recipient does not belong to the send item")
}
id := newID("dev")
createdAt := a.now().UTC()
reason := strings.TrimSpace(event.Reason)
res, err := tx.ExecContext(r.Context(), `INSERT OR IGNORE INTO delivery_events(id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, event.ID, event.Provider, queueID, sentMessageID, rfcMessageID, event.Recipient, event.Status, reason, occurredAt.UTC().Format(time.RFC3339Nano), createdAt.Format(time.RFC3339Nano))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n > 0 {
item := DeliveryEvent{ID: id, ExternalID: event.ID, Provider: event.Provider, QueueID: queueID, MessageID: sentMessageID, RFCMessageID: rfcMessageID, Recipient: event.Recipient, Status: event.Status, Reason: reason, OccurredAt: occurredAt.UTC(), CreatedAt: createdAt}
var mailboxID string
if err := tx.QueryRowContext(r.Context(), `SELECT mailbox_id FROM send_queue WHERE id=?`, queueID).Scan(&mailboxID); err != nil {
return false, err
}
if err := a.enqueueStatusWebhook(r.Context(), tx, "delivery:"+event.Provider+":"+event.ID, "delivery."+event.Status, mailboxID, item); err != nil {
return false, err
}
}
return n > 0, nil
}
func validDeliveryEventStatus(status string) bool {
switch status {
case "delivered", "bounced", "complained", "rejected", "deferred":
return true
default:
return false
}
}
func (a *App) handleOpenAPIListSends(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
limit := parseOpenAPILimit(r, 30, 100)
where := "mb.user_id=?"
args := []any{user.ID}
if mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")); mailboxID != "" {
where += " AND sq.mailbox_id=?"
args = append(args, mailboxID)
}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
if !validSendQueueStatus(status) {
badRequest(w, errors.New("invalid send queue status"))
return
}
where += " AND sq.status=?"
args = append(args, status)
}
cursorCreatedAt, cursorID, _, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
if cursorCreatedAt != "" {
where += " AND (sq.created_at<? OR (sq.created_at=? AND sq.id<?))"
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
}
args = append(args, limit+1)
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
WHERE `+where+` ORDER BY sq.created_at DESC,sq.id DESC LIMIT ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list sends")
return
}
defer rows.Close()
items := []openAPISendStatus{}
for rows.Next() {
item, err := scanSendQueueEntry(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan sends")
return
}
status := openAPISendStatusFromQueue(item, item.MailFrom)
a.applyDeliveryStatus(r.Context(), &status)
items = append(items, status)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list sends")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeSendQueueCursor(last.CreatedAt, last.QueueID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPISendEvents(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
audit, err := a.sendAuditEvents(r, item.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send events")
return
}
delivery, err := a.deliveryEvents(r, item.SentMessageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load delivery events")
return
}
respondJSON(w, http.StatusOK, map[string]any{"auditEvents": audit, "deliveryEvents": delivery})
}
func (a *App) handleOpenAPIRetrySend(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
if item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send item is not failed"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`, sendQueueStatusQueued, now, now, item.ID, sendQueueStatusFailed)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to retry send item")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusConflict, "send item status changed")
return
}
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
}
func (a *App) handleOpenAPICancelSend(w http.ResponseWriter, r *http.Request) {
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusNotFound, "send item not found")
return
}
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
badRequest(w, errors.New("send item cannot be canceled"))
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?)`, sendQueueStatusCanceled, now, item.ID, sendQueueStatusQueued, sendQueueStatusFailed)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to cancel send item")
return
}
if affected, _ := res.RowsAffected(); affected == 0 {
respondError(w, http.StatusConflict, "send item status changed")
return
}
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
}
func sendAuditInputFromEntry(item SendQueueEntry, userID, errorText string) sendAuditInput {
return sendAuditInput{QueueID: item.ID, UserID: userID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, Error: errorText}
}
func (a *App) resolveOpenAPISendQueue(r *http.Request, id string) (SendQueueEntry, error) {
user := currentUser(r)
if item, err := a.loadSendQueueEntryForUser(r.Context(), strings.TrimSpace(id), user.ID); err == nil {
return item, nil
}
return a.loadLatestSendQueueForMessage(r.Context(), strings.TrimSpace(id), user.ID)
}
func (a *App) handleOpenAPIMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleOpenAPIListAliases(w http.ResponseWriter, r *http.Request) {
limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases
WHERE (?='' OR source>? OR (source=? AND id>?)) ORDER BY source,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list aliases")
return
}
defer rows.Close()
items := []Alias{}
for rows.Next() {
item, err := scanOpenAPIAlias(rows)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan aliases")
return
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list aliases")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Source, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func (a *App) handleOpenAPIGetAlias(w http.ResponseWriter, r *http.Request) {
item, err := scanOpenAPIAlias(a.db.QueryRowContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases WHERE id=?`, chi.URLParam(r, "id")))
if err != nil {
respondError(w, http.StatusNotFound, "alias not found")
return
}
respondJSON(w, http.StatusOK, item)
}
type aliasScanner interface{ Scan(...any) error }
func scanOpenAPIAlias(row aliasScanner) (Alias, error) {
var item Alias
var enabled int
var created string
err := row.Scan(&item.ID, &item.DomainID, &item.Source, &item.Destination, &enabled, &created)
item.Enabled = intBool(enabled)
item.CreatedAt = parseTime(created)
return item, err
}
func (a *App) sendAuditEvents(r *http.Request, queueID string) ([]SendAuditEvent, error) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at FROM send_audit_events WHERE queue_id=? ORDER BY created_at,id`, queueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SendAuditEvent{}
for rows.Next() {
var item SendAuditEvent
var recipientsJSON, createdAt string
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
return nil, err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) deliveryEvents(r *http.Request, sentMessageID string) ([]DeliveryEvent, error) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at FROM delivery_events WHERE sent_message_id=? ORDER BY occurred_at,id`, sentMessageID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []DeliveryEvent{}
for rows.Next() {
var item DeliveryEvent
var occurredAt, createdAt string
if err := rows.Scan(&item.ID, &item.ExternalID, &item.Provider, &item.QueueID, &item.MessageID, &item.RFCMessageID, &item.Recipient, &item.Status, &item.Reason, &occurredAt, &createdAt); err != nil {
return nil, err
}
item.OccurredAt = parseTime(occurredAt)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
return items, rows.Err()
}
+327 -49
View File
@@ -2,7 +2,11 @@ package app
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -14,7 +18,14 @@ import (
) )
func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`) limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
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
WHERE (?='' OR name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list domains") respondError(w, http.StatusInternalServerError, "failed to list domains")
return return
@@ -33,7 +44,13 @@ func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to list domains") respondError(w, http.StatusInternalServerError, "failed to list domains")
return return
} }
respondJSON(w, http.StatusOK, map[string]any{"items": items}) next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Name, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
} }
func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) {
@@ -121,8 +138,15 @@ func (a *App) handleOpenAPIDeleteDomain(w http.ResponseWriter, r *http.Request)
} }
func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) {
limit := parseOpenAPILimit(r, 50, 100)
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`) FROM mailboxes mb JOIN users u ON u.id=mb.user_id
WHERE (?='' OR mb.address>? OR (mb.address=? AND mb.id>?)) ORDER BY mb.address,mb.id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list mailboxes") respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
return return
@@ -141,7 +165,13 @@ func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request)
respondError(w, http.StatusInternalServerError, "failed to list mailboxes") respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
return return
} }
respondJSON(w, http.StatusOK, map[string]any{"items": items}) next := ""
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
next = encodeOpenAPIListCursor(last.Address, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
} }
func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) {
@@ -185,16 +215,31 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
if displayName == "" { if displayName == "" {
displayName = address displayName = address
} }
userID, err := a.resolveMailboxOwner(r, req.UserID, req.OwnerEmail, address, displayName, req.Password) passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
userID, err := a.resolveMailboxOwnerTx(r.Context(), tx, req.UserID, req.OwnerEmail, address, displayName, string(passwordHash))
if err != nil { if err != nil {
respondMailboxOwnerError(w, err) respondMailboxOwnerError(w, err)
return return
} }
mailboxID, err := a.createMailbox(r.Context(), userID, req.DomainID, localPart, displayName, req.Password, req.QuotaMB, "active") mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
if err != nil { if err != nil {
badRequest(w, err) badRequest(w, err)
return return
} }
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
return
}
mailbox, err := a.mailboxByID(r.Context(), mailboxID) mailbox, err := a.mailboxByID(r.Context(), mailboxID)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load mailbox") respondError(w, http.StatusInternalServerError, "failed to load mailbox")
@@ -318,6 +363,52 @@ func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) respondJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
return
}
var userID string
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, chi.URLParam(r, "id")).Scan(&userID); err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?,updated_at=? WHERE id=?`, string(hash), now, userID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to reset password")
return
}
res, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?,updated_at=? WHERE user_id=?`, string(hash), now, userID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to reset mailbox passwords")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save password")
return
}
affected, _ := res.RowsAffected()
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affectedMailboxes": affected})
}
func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) { func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
var req mailComposeInput var req mailComposeInput
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -329,8 +420,31 @@ func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found") respondError(w, http.StatusNotFound, "mailbox not found")
return return
} }
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
requestJSON, _ := json.Marshal(req)
requestSum := sha256.Sum256(requestJSON)
requestHash := hex.EncodeToString(requestSum[:])
if idempotencyKey != "" {
if len(idempotencyKey) > 128 || strings.ContainsAny(idempotencyKey, "\r\n") {
badRequest(w, errors.New("invalid Idempotency-Key"))
return
}
status, replayed, err := a.reserveOpenAPISendIdempotency(r.Context(), currentUser(r).ID, idempotencyKey, requestHash)
if err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
if replayed {
w.Header().Set("Idempotency-Replayed", "true")
respondJSON(w, http.StatusOK, status)
return
}
}
msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI) msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI)
if err != nil { if err != nil {
if idempotencyKey != "" {
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=? AND sent_message_id=''`, currentUser(r).ID, idempotencyKey)
}
respondSendError(w, err) respondSendError(w, err)
return return
} }
@@ -345,6 +459,10 @@ func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
status = openAPISendStatusFromQueue(item, mb.Address) status = openAPISendStatusFromQueue(item, mb.Address)
} }
} }
a.applyDeliveryStatus(r.Context(), &status)
if idempotencyKey != "" {
_, _ = a.db.ExecContext(r.Context(), `UPDATE send_idempotency_keys SET sent_message_id=?,queue_id=? WHERE user_id=? AND idempotency_key=?`, status.MessageID, status.QueueID, currentUser(r).ID, idempotencyKey)
}
respondJSON(w, http.StatusCreated, status) respondJSON(w, http.StatusCreated, status)
} }
@@ -360,7 +478,9 @@ func (a *App) handleOpenAPISendStatus(w http.ResponseWriter, r *http.Request) {
if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil { if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil {
mailboxAddress = mb.Address mailboxAddress = mb.Address
} }
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(item, mailboxAddress)) status := openAPISendStatusFromQueue(item, mailboxAddress)
a.applyDeliveryStatus(r.Context(), &status)
respondJSON(w, http.StatusOK, status)
return return
} }
msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID) msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
@@ -383,7 +503,11 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
return return
} }
limit := parseOpenAPILimit(r, 30, 100) limit := parseOpenAPILimit(r, 30, 100)
offset := parseOpenAPIOffset(r) cursorReceivedAt, cursorID, offset, err := parseOpenAPIMessageCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
folder := strings.TrimSpace(r.URL.Query().Get("folder")) folder := strings.TrimSpace(r.URL.Query().Get("folder"))
if folder == "" { if folder == "" {
folder = "Inbox" folder = "Inbox"
@@ -399,11 +523,20 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
like := "%" + q + "%" like := "%" + q + "%"
args = append(args, like, like, like, like, like, like) args = append(args, like, like, like, like, like, like)
} }
args = append(args, limit+1, offset) if cursorReceivedAt != "" {
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 where += " AND (m.received_at<? OR (m.received_at=? AND m.id<?))"
args = append(args, cursorReceivedAt, cursorReceivedAt, cursorID)
}
args = append(args, limit+1)
query := `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 FROM messages m JOIN folders f ON f.id=m.folder_id
WHERE `+where+` WHERE ` + where + `
ORDER BY m.received_at DESC LIMIT ? OFFSET ?`, args...) ORDER BY m.received_at DESC,m.id DESC LIMIT ?`
if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load messages") respondError(w, http.StatusInternalServerError, "failed to load messages")
return return
@@ -425,7 +558,8 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
nextCursor := "" nextCursor := ""
if len(items) > limit { if len(items) > limit {
items = items[:limit] items = items[:limit]
nextCursor = strconv.Itoa(offset + limit) last := items[len(items)-1]
nextCursor = encodeOpenAPIMessageCursor(last.ReceivedAt, last.ID)
} }
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor}) respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor})
} }
@@ -459,29 +593,44 @@ func scanMailbox(row mailboxScanner) (Mailbox, error) {
} }
type openAPISendStatus struct { type openAPISendStatus struct {
ID string `json:"id"` ID string `json:"id"`
QueueID string `json:"queueId,omitempty"` QueueID string `json:"queueId,omitempty"`
Status string `json:"status"` Status string `json:"status"`
MessageID string `json:"messageId"` QueueStatus string `json:"queueStatus,omitempty"`
RFCMessageID string `json:"rfcMessageId"` MessageID string `json:"messageId"`
MailboxID string `json:"mailboxId"` RFCMessageID string `json:"rfcMessageId"`
MailboxAddress string `json:"mailboxAddress,omitempty"` MailboxID string `json:"mailboxId"`
Subject string `json:"subject,omitempty"` MailboxAddress string `json:"mailboxAddress,omitempty"`
Recipients []string `json:"recipients,omitempty"` Subject string `json:"subject,omitempty"`
AttemptCount int `json:"attemptCount,omitempty"` Recipients []string `json:"recipients,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"` AttemptCount int `json:"attemptCount,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` MaxAttempts int `json:"maxAttempts,omitempty"`
LastError string `json:"lastError,omitempty"` NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` LastError string `json:"lastError,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"` CreatedAt time.Time `json:"createdAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
RecipientStatuses []openAPIRecipientStatus `json:"recipientStatuses,omitempty"`
}
type openAPIRecipientStatus struct {
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Provider string `json:"provider,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
} }
func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus { func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus {
status := item.Status
if status == sendQueueStatusDelivered {
status = "relayed"
}
return openAPISendStatus{ return openAPISendStatus{
ID: item.ID, ID: firstNonEmpty(item.SentMessageID, item.ID),
QueueID: item.ID, QueueID: item.ID,
Status: item.Status, Status: status,
QueueStatus: item.Status,
MessageID: item.SentMessageID, MessageID: item.SentMessageID,
RFCMessageID: item.MessageID, RFCMessageID: item.MessageID,
MailboxID: item.MailboxID, MailboxID: item.MailboxID,
@@ -498,6 +647,139 @@ func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) open
} }
} }
func (a *App) reserveOpenAPISendIdempotency(ctx context.Context, userID, key, requestHash string) (openAPISendStatus, bool, error) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM send_idempotency_keys WHERE created_at<?`, a.now().UTC().Add(-24*time.Hour).Format(time.RFC3339Nano))
res, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_idempotency_keys(user_id,idempotency_key,request_hash,created_at) VALUES(?,?,?,?)`, userID, key, requestHash, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return openAPISendStatus{}, false, err
}
if n, _ := res.RowsAffected(); n > 0 {
return openAPISendStatus{}, false, nil
}
var storedHash, sentMessageID, queueID string
if err := a.db.QueryRowContext(ctx, `SELECT request_hash,sent_message_id,queue_id FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=?`, userID, key).Scan(&storedHash, &sentMessageID, &queueID); err != nil {
return openAPISendStatus{}, false, err
}
if storedHash != requestHash {
return openAPISendStatus{}, false, errors.New("Idempotency-Key was already used with a different request")
}
if sentMessageID == "" {
return openAPISendStatus{}, false, errors.New("a request with this Idempotency-Key is still processing")
}
item, err := a.loadSendQueueEntryForUser(ctx, queueID, userID)
if err != nil {
return openAPISendStatus{}, false, err
}
status := openAPISendStatusFromQueue(item, item.MailFrom)
a.applyDeliveryStatus(ctx, &status)
return status, true, nil
}
func (a *App) applyDeliveryStatus(ctx context.Context, status *openAPISendStatus) {
rows, err := a.db.QueryContext(ctx, `SELECT recipient,status,reason,provider,occurred_at FROM delivery_events
WHERE sent_message_id=? ORDER BY occurred_at DESC,id DESC`, status.MessageID)
if err != nil {
return
}
defer rows.Close()
seen := map[string]bool{}
counts := map[string]int{}
for rows.Next() {
var item openAPIRecipientStatus
var occurredAt string
if rows.Scan(&item.Recipient, &item.Status, &item.Reason, &item.Provider, &occurredAt) != nil || seen[item.Recipient] {
continue
}
seen[item.Recipient] = true
item.OccurredAt = parseTime(occurredAt)
status.RecipientStatuses = append(status.RecipientStatuses, item)
counts[item.Status]++
}
if len(status.RecipientStatuses) == 0 {
return
}
if len(status.RecipientStatuses) < len(status.Recipients) || len(counts) > 1 {
status.Status = "partial"
return
}
for _, value := range []string{"complained", "bounced", "rejected", "deferred", "delivered"} {
if counts[value] > 0 {
status.Status = value
return
}
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
type openAPIMessageCursor struct {
ReceivedAt string `json:"receivedAt"`
ID string `json:"id"`
}
type openAPIListCursor struct {
Sort string `json:"sort"`
ID string `json:"id"`
}
func encodeOpenAPIMessageCursor(receivedAt time.Time, id string) string {
payload, _ := json.Marshal(openAPIMessageCursor{ReceivedAt: receivedAt.UTC().Format(time.RFC3339Nano), ID: id})
return base64.RawURLEncoding.EncodeToString(payload)
}
func parseOpenAPIMessageCursor(raw string) (receivedAt, id string, offset int, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", 0, nil
}
if n, convErr := strconv.Atoi(raw); convErr == nil {
if n < 0 {
return "", "", 0, errors.New("invalid cursor")
}
return "", "", n, nil
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", 0, errors.New("invalid cursor")
}
var cursor openAPIMessageCursor
if err := json.Unmarshal(data, &cursor); err != nil || cursor.ReceivedAt == "" || cursor.ID == "" {
return "", "", 0, errors.New("invalid cursor")
}
if _, err := time.Parse(time.RFC3339Nano, cursor.ReceivedAt); err != nil {
return "", "", 0, errors.New("invalid cursor")
}
return cursor.ReceivedAt, cursor.ID, 0, nil
}
func encodeOpenAPIListCursor(sortValue, id string) string {
payload, _ := json.Marshal(openAPIListCursor{Sort: sortValue, ID: id})
return base64.RawURLEncoding.EncodeToString(payload)
}
func parseOpenAPIListCursor(raw string) (sortValue, id string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", nil
}
data, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("invalid cursor")
}
var cursor openAPIListCursor
if err := json.Unmarshal(data, &cursor); err != nil || cursor.Sort == "" || cursor.ID == "" {
return "", "", errors.New("invalid cursor")
}
return cursor.Sort, cursor.ID, nil
}
func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus { func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus {
recipients := append(append([]string{}, msg.To...), msg.CC...) recipients := append(append([]string{}, msg.To...), msg.CC...)
recipients = append(recipients, msg.BCC...) recipients = append(recipients, msg.BCC...)
@@ -521,12 +803,19 @@ func timePtr(t time.Time) *time.Time {
return &t return &t
} }
func (a *App) resolveMailboxOwner(r *http.Request, userID, ownerEmail, address, displayName, password string) (string, error) { func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, ownerEmail, address, displayName, passwordHash string) (string, error) {
userID = strings.TrimSpace(userID) userID = strings.TrimSpace(userID)
if userID != "" { if userID != "" {
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil { var disabled int
if err := tx.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", errNotFound
}
return "", err return "", err
} }
if intBool(disabled) {
return "", errors.New("owner user is disabled")
}
return userID, nil return userID, nil
} }
email := normalizeEmail(ownerEmail) email := normalizeEmail(ownerEmail)
@@ -537,32 +826,21 @@ func (a *App) resolveMailboxOwner(r *http.Request, userID, ownerEmail, address,
return "", errors.New("invalid owner email") return "", errors.New("invalid owner email")
} }
var existing string var existing string
err := a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing) err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
if err == nil { if err == nil {
return existing, nil return existing, nil
} }
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return "", err return "", err
} }
return a.createMailboxOwnerUser(r, email, displayName, password) userID = newID("usr")
}
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) now := a.now().UTC().Format(time.RFC3339Nano)
if displayName == "" { if displayName == "" {
displayName = email displayName = email
} }
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) _, err = tx.ExecContext(ctx, `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) VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", passwordHash, 0, now, now)
if err != nil { return userID, err
return "", err
}
return userID, nil
} }
func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error { func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error {
+66 -28
View File
@@ -15,6 +15,7 @@ import (
type contextKey string type contextKey string
const userContextKey contextKey = "user" const userContextKey contextKey = "user"
const apiTokenScopesContextKey contextKey = "api_token_scopes"
func (a *App) Router() http.Handler { func (a *App) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -75,22 +76,9 @@ func (a *App) Router() http.Handler {
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback) r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
r.With(a.requireAuth).Get("/events", a.handleEvents) r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) { r.Post("/open/v1/delivery-events", a.handleOpenAPIDeliveryWebhook)
r.Use(a.requireAPIToken) r.Route("/open", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains", a.handleOpenAPIListDomains) r.Route("/open/v1", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/open/domains", a.handleOpenAPICreateDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains/{id}", a.handleOpenAPIGetDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/open/domains/{id}", a.handleOpenAPIUpdateDomain)
r.With(a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/open/domains/{id}", a.handleOpenAPIDeleteDomain)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes", a.handleOpenAPIListMailboxes)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/open/mailboxes", a.handleOpenAPICreateMailbox)
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/open/mailboxes/{id}", a.handleOpenAPIGetMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/open/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
r.With(a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/open/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
r.With(a.requirePermission(PermissionMailSend)).Post("/open/send", a.handleOpenAPISendMail)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/send/{id}", a.handleOpenAPISendStatus)
r.With(a.requirePermission(PermissionMailRead)).Get("/open/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.requireAuth) r.Use(a.requireAuth)
@@ -179,6 +167,37 @@ func (a *App) Router() http.Handler {
return r return r
} }
func (a *App) registerOpenAPIRoutes(r chi.Router) {
r.Use(a.requireAPIToken)
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains", a.handleOpenAPIListDomains)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/domains", a.handleOpenAPICreateDomain)
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains/{id}", a.handleOpenAPIGetDomain)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/domains/{id}", a.handleOpenAPIUpdateDomain)
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/domains/{id}", a.handleOpenAPIDeleteDomain)
r.With(a.requireAPITokenScope("dns:read"), a.requireAdminAccess, a.requirePermission(PermissionDNSView)).Get("/domains/{id}/dns-records", a.handleDNSRecords)
r.With(a.requireAPITokenScope("dns:check"), a.requireAdminAccess, a.requirePermission(PermissionDNSCheck)).Post("/domains/{id}/dns-check", a.handleDNSCheck)
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes", a.handleOpenAPIListMailboxes)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/mailboxes", a.handleOpenAPICreateMailbox)
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes/{id}", a.handleOpenAPIGetMailbox)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionUsersResetPassword)).Post("/mailboxes/{id}/password", a.handleOpenAPIResetMailboxPassword)
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
r.With(a.requireAPITokenScope("messages:send"), a.requirePermission(PermissionMailSend)).Post("/send", a.handleOpenAPISendMail)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send", a.handleOpenAPIListSends)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}", a.handleOpenAPISendStatus)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}/events", a.handleOpenAPISendEvents)
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/retry", a.handleOpenAPIRetrySend)
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/cancel", a.handleOpenAPICancelSend)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/messages/{id}", a.handleOpenAPIMessage)
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailAttachments)).Get("/attachments/{id}", a.handleAttachment)
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases", a.handleOpenAPIListAliases)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesCreate)).Post("/aliases", a.handleCreateAlias)
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases/{id}", a.handleOpenAPIGetAlias)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesUpdate)).Post("/aliases/{id}", a.handleUpdateAlias)
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesDelete)).Delete("/aliases/{id}", a.handleDeleteAlias)
}
func (a *App) corsMiddleware(next http.Handler) http.Handler { func (a *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
@@ -186,7 +205,7 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Idempotency-Key")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
} }
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@@ -210,15 +229,30 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
func (a *App) requireAPIToken(next http.Handler) http.Handler { func (a *App) requireAPIToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticateAPIToken(r) user, scopes, err := a.authenticateAPIToken(r)
if err != nil { if err != nil {
respondError(w, http.StatusUnauthorized, "api token required") respondError(w, http.StatusUnauthorized, "api token required")
return return
} }
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, apiTokenScopesContextKey, scopes)
next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
func (a *App) requireAPITokenScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scopes, _ := r.Context().Value(apiTokenScopesContextKey).(map[string]bool)
if !scopes["*"] && !scopes[scope] {
respondError(w, http.StatusForbidden, "api token scope required: "+scope)
return
}
next.ServeHTTP(w, r)
})
}
}
func currentUser(r *http.Request) *User { func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User) user, _ := r.Context().Value(userContextKey).(*User)
return user return user
@@ -250,33 +284,37 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
return &u, nil return &u, nil
} }
func (a *App) authenticateAPIToken(r *http.Request) (*User, error) { func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, error) {
token := bearerToken(r) token := bearerToken(r)
if token == "" { if token == "" {
return nil, errors.New("no api token") return nil, nil, errors.New("no api token")
} }
now := a.now().UTC().Format(time.RFC3339Nano) 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 row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,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 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 > ?`, hashToken(token), now) WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
var tokenID string var tokenID, scopesJSON string
var u User var u User
var disabled, twoFactorEnabled int var disabled, twoFactorEnabled int
var created string var created string
if err := row.Scan(&tokenID, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
return nil, err return nil, nil, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled) u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if u.Disabled { if u.Disabled {
return nil, errors.New("disabled") return nil, nil, errors.New("disabled")
} }
if err := a.attachUserAuthorization(r.Context(), &u); err != nil { if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
return nil, err return nil, nil, err
} }
_, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID) _, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID)
return &u, nil scopes := map[string]bool{}
for _, scope := range jsonDecodeSlice(scopesJSON) {
scopes[scope] = true
}
return &u, scopes, nil
} }
func bearerToken(r *http.Request) string { func bearerToken(r *http.Request) string {
+18 -2
View File
@@ -365,10 +365,26 @@ func (a *App) recordSendAudit(ctx context.Context, event, status string, in send
if source == "" { if source == "" {
source = "unknown" source = "unknown"
} }
_, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at) id := newID("audit")
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano)) createdAt := a.now().UTC()
item := SendAuditEvent{ID: id, QueueID: in.QueueID, MailboxID: in.MailboxID, SentMessageID: in.SentMessageID, Source: source, Event: event, Status: status, MailFrom: normalizeEmail(in.MailFrom), HeaderFrom: normalizeEmail(in.HeaderFrom), Recipients: dedupeEmails(in.Recipients), Error: in.Error, CreatedAt: createdAt}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
a.log.Warn("failed to start send audit transaction", "event", event, "error", err)
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, item.MailFrom, item.HeaderFrom, jsonEncode(item.Recipients), in.Error, createdAt.Format(time.RFC3339Nano)); err != nil {
a.log.Warn("failed to record send audit", "event", event, "error", err) a.log.Warn("failed to record send audit", "event", event, "error", err)
return
}
if err := a.enqueueStatusWebhook(ctx, tx, "audit:"+id, "send."+event, in.MailboxID, item); err != nil {
a.log.Warn("failed to enqueue send status webhook", "event", event, "error", err)
return
}
if err := tx.Commit(); err != nil {
a.log.Warn("failed to commit send audit", "event", event, "error", err)
} }
} }
+216
View File
@@ -0,0 +1,216 @@
package app
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const statusWebhookMaxAttempts = 10
type statusWebhookEnvelope struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
Data any `json:"data"`
}
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
return nil
}
now := a.now().UTC()
id := newID("whk")
payload := jsonEncode(statusWebhookEnvelope{ID: id, Type: eventType, CreatedAt: now.Format(time.RFC3339Nano), Data: data})
_, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO status_webhook_outbox(id,event_key,event_type,mailbox_id,payload_json,next_attempt_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, eventKey, eventType, mailboxID, payload, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
}
func (a *App) statusWebhookWorker(ctx context.Context) {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
return
}
a.log.Info("status webhook worker started")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
if err := a.processDueStatusWebhooks(ctx); err != nil && !errors.Is(err, context.Canceled) {
a.log.Warn("status webhook worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("status webhook worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
return nil
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), statusWebhookMaxAttempts)
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM status_webhook_outbox
WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? ORDER BY next_attempt_at,created_at LIMIT 20`, statusWebhookMaxAttempts, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
type item struct {
id, payload string
attempt int
}
items := []item{}
for rows.Next() {
var value item
if err := rows.Scan(&value.id, &value.payload, &value.attempt); err != nil {
rows.Close()
return err
}
items = append(items, value)
}
if err := rows.Close(); err != nil {
return err
}
for _, value := range items {
if err := a.deliverStatusWebhook(ctx, value.id, []byte(value.payload)); err != nil {
now := a.now().UTC()
next := now.Add(sendRetryDelay(value.attempt + 1))
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), value.id)
continue
}
now := a.now().UTC().Format(time.RFC3339Nano)
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, now, now, value.id)
}
return nil
}
func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload []byte) error {
target, err := a.validatedStatusWebhookURL(ctx)
if err != nil {
return err
}
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
_, _ = mac.Write([]byte(timestamp + "."))
_, _ = mac.Write(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "LanQin-Email-Webhook/1.0")
req.Header.Set("X-LanQin-Webhook-Id", eventID)
req.Header.Set("X-LanQin-Timestamp", timestamp)
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Transport: &http.Transport{DialContext: a.statusWebhookDialContext, DisableKeepAlives: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second},
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status webhook returned %d", resp.StatusCode)
}
return nil
}
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
}
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
return nil, errors.New("invalid status webhook URL")
}
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
return nil, errors.New("status webhook URL must use HTTPS")
}
if !a.cfg.StatusWebhookAllowPrivateHosts {
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
return nil, err
}
}
return target, nil
}
func (a *App) statusWebhookDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
if a.cfg.StatusWebhookAllowPrivateHosts {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if !isPublicStatusWebhookIP(ip) {
return nil, errors.New("private or local status webhook hosts are not allowed")
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
var lastErr error
for _, ip := range ips {
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("status webhook host resolved without usable addresses")
}
return nil, lastErr
}
func validatePublicWebhookHost(ctx context.Context, host string) error {
if strings.EqualFold(host, "localhost") {
return errors.New("localhost status webhook hosts are not allowed")
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("failed to resolve status webhook host: %w", err)
}
for _, ip := range ips {
if !isPublicStatusWebhookIP(ip) {
return errors.New("private or local status webhook hosts are not allowed")
}
}
return nil
}
func isPublicStatusWebhookIP(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsGlobalUnicast() && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
}
func truncateWebhookError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 1000 {
return value[:1000]
}
return value
}
+15
View File
@@ -29,10 +29,25 @@ type APIToken struct {
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"` ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
Scopes []string `json:"scopes"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type DeliveryEvent struct {
ID string `json:"id"`
ExternalID string `json:"externalId"`
Provider string `json:"provider"`
QueueID string `json:"queueId"`
MessageID string `json:"messageId"`
RFCMessageID string `json:"rfcMessageId"`
Recipient string `json:"recipient"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Domain struct { type Domain struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
+1 -1
View File
@@ -51,7 +51,7 @@ export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number
export type PermissionGroupSummary = { id: string; name: string } 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 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 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 APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: 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 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 } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
+2 -2
View File
@@ -40,8 +40,8 @@ export const api = {
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }), 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) }), 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"), 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) }), createApiToken: (payload: { name: string; expiresAt?: string; scopes: 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) }), updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => 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" }), 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" }), 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 }) }), enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
+51 -11
View File
@@ -148,12 +148,12 @@ export function ProfilePage() {
onError: (error) => toast({ title: "关闭失败", description: error.message }), onError: (error) => toast({ title: "关闭失败", description: error.message }),
}) })
const createApiToken = useMutation({ const createApiToken = useMutation({
mutationFn: (payload: { name: string; expiresAt?: string }) => api.createApiToken(payload), mutationFn: (payload: { name: string; expiresAt?: string; scopes: string[] }) => api.createApiToken(payload),
onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已创建" }); return res }, onSuccess: (res) => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已创建" }); return res },
onError: (error) => toast({ title: "创建失败", description: error.message }), onError: (error) => toast({ title: "创建失败", description: error.message }),
}) })
const updateApiToken = useMutation({ const updateApiToken = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: { name?: string; expiresAt?: string; disabled?: boolean } }) => api.updateApiToken(id, payload), mutationFn: ({ id, payload }: { id: string; payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] } }) => api.updateApiToken(id, payload),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已更新" }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-tokens"] }); toast({ title: "API Token 已更新" }) },
onError: (error) => toast({ title: "更新失败", description: error.message }), onError: (error) => toast({ title: "更新失败", description: error.message }),
}) })
@@ -1005,9 +1005,18 @@ 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 apiTokenScopeOptions = [
["messages:send", "发送邮件"], ["messages:read", "读取邮件与投递状态"], ["messages:manage", "重试或取消发送"],
["domains:read", "查看域名"], ["domains:write", "管理域名"], ["mailboxes:read", "查看邮箱"], ["mailboxes:write", "管理邮箱"],
["dns:read", "查看 DNS"], ["dns:check", "执行 DNS 检测"], ["aliases:read", "查看别名"], ["aliases:write", "管理别名"],
] as const
function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelete, onCopy }: { items: APIToken[]; loading: boolean; pending: boolean; onCreate: (payload: { name: string; expiresAt?: string; scopes: string[] }) => Promise<{ token: string; item: APIToken }>; onUpdate: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => void; onDelete: (id: string) => void; onCopy: (text: string) => void }) {
const [createdToken, setCreatedToken] = React.useState("") const [createdToken, setCreatedToken] = React.useState("")
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null) const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
const [scopes, setScopes] = React.useState<string[]>(["messages:send", "messages:read"])
const [editingToken, setEditingToken] = React.useState<APIToken | null>(null)
const [editingScopes, setEditingScopes] = React.useState<string[]>([])
const defaultExpiresAt = React.useMemo(() => dateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)), []) const defaultExpiresAt = React.useMemo(() => dateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)), [])
async function submit(event: React.FormEvent<HTMLFormElement>) { async function submit(event: React.FormEvent<HTMLFormElement>) {
@@ -1016,9 +1025,10 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
const form = new FormData(target) const form = new FormData(target)
const expiresAt = dateInputToISOString(String(form.get("expiresAt") || "")) const expiresAt = dateInputToISOString(String(form.get("expiresAt") || ""))
try { try {
const res = await onCreate({ name: String(form.get("name") || ""), expiresAt }) const res = await onCreate({ name: String(form.get("name") || ""), expiresAt, scopes })
setCreatedToken(res.token) setCreatedToken(res.token)
target.reset() target.reset()
setScopes(["messages:send", "messages:read"])
} catch { } catch {
// Mutation-level error handling already shows the toast. // Mutation-level error handling already shows the toast.
} }
@@ -1047,14 +1057,22 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
</div> </div>
)} )}
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end" onSubmit={submit}> <form className="space-y-4" onSubmit={submit}>
<Field label="名称"> <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end">
<Input name="name" required maxLength={80} placeholder="billing-system" /> <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 || scopes.length === 0}>{pending ? "创建中..." : "创建 Token"}</Button>
</div>
<Field label="授权范围">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{apiTokenScopeOptions.map(([value, label]) => (
<label key={value} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox checked={scopes.includes(value)} onCheckedChange={(checked) => setScopes((current) => checked === true ? [...current, value] : current.filter((scope) => scope !== value))} />
<span>{label}</span>
</label>
))}
</div>
</Field> </Field>
<Field label="到期日期">
<Input name="expiresAt" type="date" defaultValue={defaultExpiresAt} min={dateInputValue(new Date())} required />
</Field>
<Button disabled={pending}>{pending ? "创建中..." : "创建 Token"}</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -1076,8 +1094,12 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
<span>{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"}</span> <span>{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"}</span>
<span>使{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span> <span>使{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span>
</div> </div>
<div className="mt-2 flex flex-wrap gap-1">
{(item.scopes || ["*"]).map((scope) => <Badge key={scope} variant="outline">{scope}</Badge>)}
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={pending} onClick={() => { setEditingToken(item); setEditingScopes(item.scopes?.includes("*") ? ["messages:send", "messages:read"] : item.scopes || []) }}></Button>
<Button type="button" variant="outline" size="sm" disabled={pending || expired} onClick={() => onUpdate(item.id, { disabled: !item.disabled })}>{item.disabled ? "启用" : "禁用"}</Button> <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> <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>
@@ -1088,6 +1110,24 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={!!editingToken} onOpenChange={(open) => { if (!open) setEditingToken(null) }}>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader><DialogTitle> Token </DialogTitle></DialogHeader>
<div className="grid gap-2 sm:grid-cols-2">
{apiTokenScopeOptions.map(([value, label]) => (
<label key={value} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<Checkbox checked={editingScopes.includes(value)} onCheckedChange={(checked) => setEditingScopes((current) => checked === true ? [...current, value] : current.filter((scope) => scope !== value))} />
<span>{label}</span>
</label>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditingToken(null)}></Button>
<Button type="button" disabled={pending || editingScopes.length === 0} onClick={() => { if (editingToken) onUpdate(editingToken.id, { scopes: editingScopes }); setEditingToken(null) }}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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()} /> <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> </div>
) )
+10
View File
@@ -97,6 +97,16 @@ LANQIN_SMTP_PASSWORD=
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。 # 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
LANQIN_SMTP_REQUIRE_TLS=false LANQIN_SMTP_REQUIRE_TLS=false
# 开放 API 最终投递事件回调的 HMAC-SHA256 密钥。生产环境请使用高强度随机值。
# 未配置时 /api/open/v1/delivery-events 返回 503。
LANQIN_DELIVERY_WEBHOOK_SECRET=
# 可选:把发送队列与最终投递状态主动推送给外部系统。URL 默认必须为公网 HTTPS。
LANQIN_STATUS_WEBHOOK_URL=
LANQIN_STATUS_WEBHOOK_SECRET=
# 仅可信内网或本地测试可开启;开启后也允许 HTTP 与私网目标。
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。 # 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
LANQIN_SUBMISSION_ADDR= LANQIN_SUBMISSION_ADDR=
LANQIN_SUBMISSION_TLS_ADDR= LANQIN_SUBMISSION_TLS_ADDR=
+18
View File
@@ -174,6 +174,24 @@ LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false LANQIN_SMTP_REQUIRE_TLS=false
``` ```
如需把上游服务商或 DSN 处理器的最终送达、退信、投诉、拒收事件写回开放 API,请设置:
```env
LANQIN_DELIVERY_WEBHOOK_SECRET=replace-with-a-long-random-secret
```
回调地址、签名算法和事件格式见仓库中的 `docs/API.md``docs/openapi.json`。该接口未配置密钥时返回 `503`
如需把状态变化主动推送到集成方,可额外设置:
```env
LANQIN_STATUS_WEBHOOK_URL=https://integration.example.com/hooks/lanqin
LANQIN_STATUS_WEBHOOK_SECRET=replace-with-another-long-random-secret
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
```
事件先写入 SQLite outbox,再由后台 worker 投递;非 2xx 响应会按退避策略重试,最多 10 次。默认只允许公网 HTTPS,禁止重定向、URL 用户信息和私网/本机目标。只有可信内网或本地测试才应开启 `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS`
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT` Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查: 如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
+96 -41
View File
@@ -1,8 +1,12 @@
# LanQin Email API # LanQin Email API
LanQin Email exposes integration-oriented APIs under `/api/open`. LanQin Email exposes versioned integration APIs under `/api/open/v1`. The original `/api/open` paths remain compatibility aliases.
这些接口用于外部系统集成,统一放在 `/api/open` 。它们不是匿名公开接口,只接受 API Token,不接受浏览器登录 Session Cookie。 这些接口用于外部系统集成,稳定版本入口为 `/api/open/v1`。原 `/api/open` 路径继续作为兼容别名。它们不是匿名公开接口,只接受 API Token,不接受浏览器登录 Session Cookie。
Machine-readable OpenAPI 3.1 contract: [`docs/openapi.json`](./openapi.json).
机器可读的 OpenAPI 3.1 契约见 [`docs/openapi.json`](./openapi.json)。
## Base URL ## Base URL
@@ -28,6 +32,7 @@ The API uses standard HTTP status codes:
| `401 Unauthorized` | Missing or invalid API token / 缺少或无效的 API Token | | `401 Unauthorized` | Missing or invalid API token / 缺少或无效的 API Token |
| `403 Forbidden` | Token lacks required permissions / Token 缺少所需权限 | | `403 Forbidden` | Token lacks required permissions / Token 缺少所需权限 |
| `404 Not Found` | Resource does not exist / 资源不存在 | | `404 Not Found` | Resource does not exist / 资源不存在 |
| `409 Conflict` | Idempotency key conflict or concurrent status change / 幂等键冲突或状态并发变化 |
| `429 Too Many Requests` | Rate limit exceeded / 超过频率限制 | | `429 Too Many Requests` | Rate limit exceeded / 超过频率限制 |
| `500 Internal Server Error` | Server error / 服务器错误 | | `500 Internal Server Error` | Server error / 服务器错误 |
@@ -101,27 +106,32 @@ Tokens created without a custom expiration default to 90 days. You can disable o
如果没有自定义到期时间,Token 默认 90 天后过期。你可以在同一个个人中心页面中禁用或撤销 Token。 如果没有自定义到期时间,Token 默认 90 天后过期。你可以在同一个个人中心页面中禁用或撤销 Token。
Each token has independent scopes. Scopes only reduce the permissions of the owning user; they never grant permissions the user does not already have. Existing tokens created before scope support are migrated to `*` for compatibility.
每个 Token 都有独立 scope。scope 只会收缩 Token 所属用户已有的权限,不会授予用户原本没有的权限。scope 功能上线前创建的 Token 会迁移为 `*`,以保持兼容。
| Scope | Purpose |
|---|---|
| `domains:read` / `domains:write` | View or manage sending domains |
| `mailboxes:read` / `mailboxes:write` | View or manage mailboxes; password reset is a write operation |
| `messages:read` / `messages:send` / `messages:manage` | Read messages/status, send, or retry/cancel |
| `aliases:read` / `aliases:write` | View or manage aliases |
| `dns:read` / `dns:check` | View required records or execute DNS checks |
| `*` | Compatibility wildcard; avoid for new integrations |
## Permissions ## Permissions
All Open API endpoints require an API token with appropriate permissions and role requirements: All Open API endpoints require an API token with appropriate permissions and role requirements:
所有 Open API 接口都需要具备相应权限和角色的 API Token: 所有 Open API 接口都需要具备相应权限和角色的 API Token:
| Endpoint 接口 | Required Permission 所需权限 | Required Role 所需角色 | | Endpoint group | Required scope | Role |
|----------|-------------------|---------------| |---|---|---|
| `GET /api/open/domains` | any of `admin.domains.view`, `admin.dns.view`, `admin.mailboxes.view`, `admin.aliases.view`, `admin.settings.view`, `admin.templates.view` | admin | | Domains | `domains:read` or `domains:write` | admin |
| `POST /api/open/domains` | `admin.domains.create` | admin | | Mailboxes | `mailboxes:read` or `mailboxes:write` | admin |
| `GET /api/open/domains/{id}` | any of `admin.domains.view`, `admin.dns.view`, `admin.mailboxes.view`, `admin.aliases.view`, `admin.settings.view`, `admin.templates.view` | admin | | DNS | `dns:read` or `dns:check` | admin |
| `POST /api/open/domains/{id}` | `admin.domains.update` | admin | | Aliases | `aliases:read` or `aliases:write` | admin |
| `DELETE /api/open/domains/{id}` | `admin.domains.delete` | admin | | Send / status / messages | `messages:send`, `messages:read`, or `messages:manage` | user or admin |
| `GET /api/open/mailboxes` | `admin.mailboxes.view` or `admin.messages.view` | admin |
| `POST /api/open/mailboxes` | `admin.mailboxes.create` | admin |
| `GET /api/open/mailboxes/{id}` | `admin.mailboxes.view` or `admin.messages.view` | admin |
| `POST /api/open/mailboxes/{id}` | `admin.mailboxes.update` | admin |
| `DELETE /api/open/mailboxes/{id}` | `admin.mailboxes.delete` | admin |
| `POST /api/open/send` | `mail.messages.send` | user or admin |
| `GET /api/open/send/{id}` | `mail.messages.read` | user or admin |
| `GET /api/open/mailboxes/{id}/messages` | `mail.messages.read` | user or admin |
**Notes:** **Notes:**
- Admin endpoints check for `requireAdminAccess` (role must be `admin`). - Admin endpoints check for `requireAdminAccess` (role must be `admin`).
@@ -138,7 +148,7 @@ All Open API endpoints require an API token with appropriate permissions and rol
### List domains ### List domains
```http ```http
GET /api/open/domains GET /api/open/v1/domains
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -182,7 +192,7 @@ Authorization: Bearer lq_xxx
### Create domain ### Create domain
```http ```http
POST /api/open/domains POST /api/open/v1/domains
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
Content-Type: application/json Content-Type: application/json
@@ -221,7 +231,7 @@ Content-Type: application/json
### Get domain ### Get domain
```http ```http
GET /api/open/domains/{id} GET /api/open/v1/domains/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -234,7 +244,7 @@ Authorization: Bearer lq_xxx
### Update domain status ### Update domain status
```http ```http
POST /api/open/domains/{id} POST /api/open/v1/domains/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
Content-Type: application/json Content-Type: application/json
@@ -262,7 +272,7 @@ Content-Type: application/json
### Delete domain ### Delete domain
```http ```http
DELETE /api/open/domains/{id} DELETE /api/open/v1/domains/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -289,7 +299,7 @@ Authorization: Bearer lq_xxx
### List mailboxes ### List mailboxes
```http ```http
GET /api/open/mailboxes GET /api/open/v1/mailboxes
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -323,7 +333,7 @@ Authorization: Bearer lq_xxx
### Create mailbox ### Create mailbox
```http ```http
POST /api/open/mailboxes POST /api/open/v1/mailboxes
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
Content-Type: application/json Content-Type: application/json
@@ -370,7 +380,7 @@ Content-Type: application/json
### Get mailbox ### Get mailbox
```http ```http
GET /api/open/mailboxes/{id} GET /api/open/v1/mailboxes/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -383,7 +393,7 @@ Authorization: Bearer lq_xxx
### Update mailbox ### Update mailbox
```http ```http
POST /api/open/mailboxes/{id} POST /api/open/v1/mailboxes/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
Content-Type: application/json Content-Type: application/json
@@ -408,7 +418,7 @@ All fields are optional. Omitted (or empty / non-positive) fields keep their cur
### Delete mailbox ### Delete mailbox
```http ```http
DELETE /api/open/mailboxes/{id} DELETE /api/open/v1/mailboxes/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -433,8 +443,9 @@ Authorization: Bearer lq_xxx
## Send Mail ## Send Mail
```http ```http
POST /api/open/send POST /api/open/v1/send
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
Idempotency-Key: invoice-2026-0001
Content-Type: application/json Content-Type: application/json
{ {
@@ -505,7 +516,7 @@ Total attachment size is limited by the sender's permission group (`maxAttachmen
| Field 字段 | Description 说明 | | Field 字段 | Description 说明 |
|-------|-------------| |-------|-------------|
| `id` | Send identifier; use it with `GET /api/open/send/{id}` / 发信标识,可配合 `GET /api/open/send/{id}` 使用 | | `id` | Send identifier; use it with `GET /api/open/v1/send/{id}` / 发信标识,可配合 `GET /api/open/v1/send/{id}` 使用 |
| `queueId` | SMTP queue item id. Omitted when the message was only `accepted` / SMTP 队列项 ID;仅 `accepted` 时不返回 | | `queueId` | SMTP queue item id. Omitted when the message was only `accepted` / SMTP 队列项 ID;仅 `accepted` 时不返回 |
| `status` | Delivery status, see values below / 投递状态,见下方取值 | | `status` | Delivery status, see values below / 投递状态,见下方取值 |
| `messageId` | Internal stored message id / 内部存储的消息 ID | | `messageId` | Internal stored message id / 内部存储的消息 ID |
@@ -520,6 +531,10 @@ When SMTP delivery is not configured, the message can be stored as accepted with
如果没有配置 SMTP 投递,邮件可能只会进入 `accepted` 状态,不会产生 `queueId` 如果没有配置 SMTP 投递,邮件可能只会进入 `accepted` 状态,不会产生 `queueId`
`id` is always the stable stored send id (`mail_*`). `queueId` is the queue item (`snd_*`) and may be absent. A repeated request with the same `Idempotency-Key` and identical body returns the original send with `200` and `Idempotency-Replayed: true`; reusing the key with a different body returns `409`. Keys are retained for 24 hours.
`id` 始终是稳定的发送邮件 ID`mail_*`);`queueId` 是队列项 ID`snd_*`),可能不存在。相同 `Idempotency-Key` 与相同请求体重试时返回原发送结果、状态码 `200`,并带 `Idempotency-Replayed: true`;相同 key 配不同请求体返回 `409`。key 保留 24 小时。
Current status values: Current status values:
当前状态取值: 当前状态取值:
@@ -527,18 +542,22 @@ Current status values:
- `accepted`: message was accepted and stored, but no SMTP queue item exists. - `accepted`: message was accepted and stored, but no SMTP queue item exists.
- `queued`: queued for SMTP delivery. - `queued`: queued for SMTP delivery.
- `sending`: currently being delivered. - `sending`: currently being delivered.
- `delivered`: SMTP delivery succeeded. - `relayed`: the configured upstream SMTP server accepted the message; this is not final recipient delivery.
- `failed`: delivery failed and may be retried. - `failed`: delivery failed and may be retried.
- `canceled`: delivery was canceled. - `canceled`: delivery was canceled.
- `delivered`, `bounced`, `complained`, `rejected`, `deferred`: final per-recipient provider/DSN event.
- `partial`: final events currently differ between recipients or only cover part of the recipient list.
<br> <br>
- `accepted`:邮件已被接受并存储,但没有 SMTP 队列项。 - `accepted`:邮件已被接受并存储,但没有 SMTP 队列项。
- `queued`:已进入 SMTP 投递队列。 - `queued`:已进入 SMTP 投递队列。
- `sending`:正在投递中。 - `sending`:正在投递中。
- `delivered`SMTP 投递成功。 - `relayed`配置的上游 SMTP 已接受邮件,但这不代表最终收件成功。
- `failed`:投递失败,可能会重试。 - `failed`:投递失败,可能会重试。
- `canceled`:投递已取消。 - `canceled`:投递已取消。
- `delivered``bounced``complained``rejected``deferred`:每个收件人的最终供应商或 DSN 事件。
- `partial`:不同收件人的最终状态不同,或当前只收到了部分收件人的事件。
**Error cases:** **Error cases:**
@@ -550,31 +569,31 @@ Current status values:
| `429` | SMTP send rate limit exceeded / 超过 SMTP 发信频率限制 | | `429` | SMTP send rate limit exceeded / 超过 SMTP 发信频率限制 |
| `507` | Mailbox quota exceeded / 邮箱配额已满 | | `507` | Mailbox quota exceeded / 邮箱配额已满 |
Bounce, complaint, rejection, and provider-specific delivery events require future webhook or delivery-event integration. Final delivery events are exposed in `recipientStatuses` and through `GET /api/open/v1/send/{id}/events`.
退信、投诉、拒收等更细状态需要后续接入投递事件或 webhook 后才能完整提供 最终投递事件会出现在 `recipientStatuses`,完整时间线可通过 `GET /api/open/v1/send/{id}/events` 获取
## Send Status ## Send Status
```http ```http
GET /api/open/send/{id} GET /api/open/v1/send/{id}
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
**Status:** `200 OK` or `404 Not Found` **Status:** `200 OK` or `404 Not Found`
`id` can be the value returned by `POST /api/open/send`. If a queue item exists, it can also be the queue id. `id` can be the value returned by `POST /api/open/v1/send`. If a queue item exists, it can also be the queue id.
`id` 可以使用发信接口返回的 `id`;如果存在队列项,也可以使用 `queueId` `id` 可以使用发信接口返回的 `id`;如果存在队列项,也可以使用 `queueId`
**Response:** Same shape as the `POST /api/open/send` response. Only messages belonging to the token user's mailboxes are returned; otherwise `404`. **Response:** Same shape as the `POST /api/open/v1/send` response. Only messages belonging to the token user's mailboxes are returned; otherwise `404`.
**响应:** 结构与 `POST /api/open/send` 的响应相同。只会返回属于 Token 拥有者邮箱的邮件,否则返回 `404` **响应:** 结构与 `POST /api/open/v1/send` 的响应相同。只会返回属于 Token 拥有者邮箱的邮件,否则返回 `404`
## Received Messages ## Received Messages
```http ```http
GET /api/open/mailboxes/{id}/messages?folder=Inbox&limit=30&cursor=0&q=keyword GET /api/open/v1/mailboxes/{id}/messages?folder=Inbox&limit=30&cursor=opaque&q=keyword
Authorization: Bearer lq_xxx Authorization: Bearer lq_xxx
``` ```
@@ -586,7 +605,7 @@ Query parameters:
- `folder`: folder name. Defaults to `Inbox`; use `all` for all folders. - `folder`: folder name. Defaults to `Inbox`; use `all` for all folders.
- `limit`: page size, defaults to `30`, maximum `100`. - `limit`: page size, defaults to `30`, maximum `100`.
- `cursor`: numeric offset. Pass back the `nextCursor` value from the previous response to fetch the next page. - `cursor`: opaque stable cursor. Pass back `nextCursor` unchanged. Numeric offsets remain accepted for compatibility.
- `q`: optional search keyword. Matches subject, from, to, snippet, and body text. - `q`: optional search keyword. Matches subject, from, to, snippet, and body text.
<br> <br>
@@ -623,8 +642,44 @@ Response:
`nextCursor` is empty when there are no more pages. Otherwise it contains the offset to pass as `cursor` for the next request. `nextCursor` is empty when there are no more pages. Otherwise it contains the offset to pass as `cursor` for the next request.
当没有更多分页时,`nextCursor` 为空字符串;否则它是下次请求应作为 `cursor` 传入的偏移量 当没有更多分页时,`nextCursor` 为空字符串;否则应将它原样作为下一次请求的 `cursor` 传入。
Users can only read messages from their own active mailboxes. Users can only read messages from their own active mailboxes. Fetch message bodies and attachment metadata with `GET /api/open/v1/messages/{id}`; download an owned attachment with `GET /api/open/v1/attachments/{id}`.
用户只能读取自己拥有的 active 邮箱。 用户只能读取自己拥有的 active 邮箱。
## Additional V1 Endpoints / 其他 V1 接口
- `GET /api/open/v1/send`: paginated send records.
- `GET /api/open/v1/send/{id}/events`: queue audit and final delivery events.
- `POST /api/open/v1/send/{id}/retry`: retry a failed queue item.
- `POST /api/open/v1/send/{id}/cancel`: cancel a queued or failed item.
- `POST /api/open/v1/mailboxes/{id}/password`: reset the owner user's password and all mailbox passwords owned by that user.
- `GET /api/open/v1/domains/{id}/dns-records` and `POST .../dns-check`: DNS configuration and check.
- `/api/open/v1/aliases`: alias CRUD.
Domain names and mailbox addresses are immutable. Renaming them requires a storage/identity migration and is intentionally not exposed as a normal update operation.
域名名称和邮箱地址不可直接修改。重命名需要迁移存储路径及身份信息,因此不作为普通更新操作开放。
## Delivery Event Webhook / 投递事件回调
Configure `LANQIN_DELIVERY_WEBHOOK_SECRET`, then post up to 100 events to `POST /api/open/v1/delivery-events`. This endpoint does not accept an API Token. Set the Unix timestamp in `X-LanQin-Timestamp`, compute `HMAC-SHA256(secret, timestamp + "." + rawBody)`, and send the lowercase hexadecimal digest as `X-LanQin-Signature: sha256=<digest>`. Timestamps outside five minutes are rejected. `(provider, event id)` is idempotent.
配置 `LANQIN_DELIVERY_WEBHOOK_SECRET` 后,可向 `POST /api/open/v1/delivery-events` 一次提交最多 100 条事件。该接口不接受 API Token。将 Unix 时间戳放入 `X-LanQin-Timestamp`,计算 `HMAC-SHA256(secret, timestamp + "." + 原始请求体)`,再以 `X-LanQin-Signature: sha256=<小写十六进制>` 发送。超过五分钟的时间戳会被拒绝;`(provider, event id)` 具备幂等性。
Accepted event statuses: `delivered`, `bounced`, `complained`, `rejected`, `deferred`. Every event must identify an existing send using `queueId`, `messageId`, or `rfcMessageId`, and its recipient must belong to that send.
## Outbound Status Webhook / 主动状态推送
Set `LANQIN_STATUS_WEBHOOK_URL` and `LANQIN_STATUS_WEBHOOK_SECRET` to receive status changes proactively. Events are persisted in a SQLite outbox before delivery. Non-2xx responses are retried with backoff up to 10 attempts. Delivered and retry-exhausted records are removed after 30 days.
设置 `LANQIN_STATUS_WEBHOOK_URL``LANQIN_STATUS_WEBHOOK_SECRET` 后,可主动接收状态变化。事件会先持久化到 SQLite outbox,非 2xx 响应会按退避策略重试,最多 10 次;已送达和重试耗尽的记录会在 30 天后清理。
Outbound requests include `X-LanQin-Webhook-Id`, `X-LanQin-Timestamp`, and `X-LanQin-Signature`. Signature calculation is the same HMAC-SHA256 construction used by the inbound delivery-event endpoint: `HMAC(secret, timestamp + "." + rawBody)`. Event types include `send.accepted`, `send.queued`, `send.retry`, `send.delivered` (upstream SMTP accepted), `send.failed`, `send.canceled`, and `delivery.<final-status>`.
出站请求包含 `X-LanQin-Webhook-Id``X-LanQin-Timestamp``X-LanQin-Signature`。签名算法与入站投递事件相同:`HMAC(secret, timestamp + "." + 原始请求体)`。事件类型包括 `send.accepted``send.queued``send.retry``send.delivered`(上游 SMTP 接受)、`send.failed``send.canceled``delivery.<最终状态>`
The target must be a public HTTPS URL by default. Redirects, URL credentials, loopback, private, link-local, and unspecified addresses are rejected. `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=true` relaxes this for explicitly trusted private deployments and also permits HTTP.
目标地址默认必须是公网 HTTPS。重定向、URL 用户信息、loopback、私网、链路本地和未指定地址都会被拒绝。只有明确可信的私有部署才应设置 `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=true`;开启后也允许 HTTP。
+99
View File
@@ -0,0 +1,99 @@
{
"openapi": "3.1.0",
"info": {
"title": "LanQin Email Open API",
"version": "1.0.0",
"description": "Versioned integration API. The unversioned /api/open routes are compatibility aliases for /api/open/v1."
},
"servers": [{ "url": "/api/open/v1" }],
"security": [{ "bearerAuth": [] }],
"paths": {
"/domains": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated domains" } } }, "post": { "responses": { "201": { "description": "Domain created" } } } },
"/domains/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Domain" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Domain updated" } } }, "delete": { "responses": { "200": { "description": "Domain deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/domains/{id}/dns-records": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Required DNS records" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/domains/{id}/dns-check": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "DNS check result" } } } },
"/mailboxes": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated mailboxes" } } }, "post": { "responses": { "201": { "description": "Mailbox created" } } } },
"/mailboxes/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Mailbox" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Mailbox updated" } } }, "delete": { "responses": { "200": { "description": "Mailbox deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/mailboxes/{id}/password": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Owner and mailbox passwords reset" } } } },
"/mailboxes/{id}/messages": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }, { "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "get": { "responses": { "200": { "description": "Paginated messages" } } } },
"/messages/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Message detail" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/attachments/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Attachment bytes" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/send": {
"get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated sends" } } },
"post": {
"parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } } },
"responses": { "200": { "description": "Idempotent replay", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "201": { "description": "Queued", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "409": { "$ref": "#/components/responses/Conflict" } }
}
},
"/send/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Send status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/send/{id}/events": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Queue audit and delivery events" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/send/{id}/retry": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Send requeued" }, "409": { "$ref": "#/components/responses/Conflict" } } } },
"/send/{id}/cancel": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Send canceled" }, "409": { "$ref": "#/components/responses/Conflict" } } } },
"/aliases": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated aliases" } } }, "post": { "responses": { "201": { "description": "Alias created" } } } },
"/aliases/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Alias" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Alias updated" } } }, "delete": { "responses": { "200": { "description": "Alias deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
"/delivery-events": {
"post": {
"security": [],
"description": "HMAC-SHA256 signed delivery event callback. Sign timestamp + '.' + raw body.",
"parameters": [
{ "name": "X-LanQin-Timestamp", "in": "header", "required": true, "schema": { "type": "string" } },
{ "name": "X-LanQin-Signature", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^sha256=[a-f0-9]{64}$" } }
],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["events"], "properties": { "events": { "type": "array", "minItems": 1, "maxItems": 100, "items": { "$ref": "#/components/schemas/DeliveryEventInput" } } } } } } },
"responses": { "200": { "description": "Events stored or deduplicated" }, "401": { "$ref": "#/components/responses/Unauthorized" } }
}
}
},
"components": {
"securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "lq_* API Token" } },
"parameters": {
"IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": false, "description": "Up to 128 characters; retained for 24 hours.", "schema": { "type": "string", "maxLength": 128 } },
"ResourceId": { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
"Limit": { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100 } },
"Cursor": { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }
},
"responses": {
"Unauthorized": { "description": "Missing or invalid authentication" },
"NotFound": { "description": "Resource not found or not owned by the token user" },
"Conflict": { "description": "Idempotency key conflict or concurrent state change" }
},
"schemas": {
"SendRequest": {
"type": "object", "required": ["mailboxId"],
"properties": {
"mailboxId": { "type": "string" }, "from": { "type": "string" }, "fromName": { "type": "string" },
"to": { "type": "array", "items": { "type": "string", "format": "email" } },
"cc": { "type": "array", "items": { "type": "string", "format": "email" } },
"bcc": { "type": "array", "items": { "type": "string", "format": "email" } },
"subject": { "type": "string" }, "text": { "type": "string" }, "html": { "type": "string" },
"attachments": { "type": "array", "items": { "$ref": "#/components/schemas/AttachmentInput" } }
}
},
"AttachmentInput": { "type": "object", "required": ["filename", "contentBase64"], "properties": { "filename": { "type": "string" }, "contentType": { "type": "string" }, "contentBase64": { "type": "string", "contentEncoding": "base64" } } },
"SendStatus": {
"type": "object", "required": ["id", "status", "messageId", "rfcMessageId", "mailboxId", "createdAt"],
"properties": {
"id": { "type": "string", "description": "Stable sent message id (mail_*)." },
"queueId": { "type": "string", "description": "Internal queue id (snd_*)." },
"status": { "type": "string", "enum": ["accepted", "queued", "sending", "relayed", "failed", "canceled", "delivered", "bounced", "complained", "rejected", "deferred", "partial"] },
"queueStatus": { "type": "string", "enum": ["queued", "sending", "delivered", "failed", "canceled"] },
"messageId": { "type": "string" }, "rfcMessageId": { "type": "string" }, "mailboxId": { "type": "string" },
"mailboxAddress": { "type": "string" }, "subject": { "type": "string" }, "recipients": { "type": "array", "items": { "type": "string" } },
"recipientStatuses": { "type": "array", "items": { "$ref": "#/components/schemas/RecipientStatus" } },
"createdAt": { "type": "string", "format": "date-time" }
}
},
"RecipientStatus": { "type": "object", "required": ["recipient", "status", "occurredAt"], "properties": { "recipient": { "type": "string" }, "status": { "type": "string" }, "reason": { "type": "string" }, "provider": { "type": "string" }, "occurredAt": { "type": "string", "format": "date-time" } } },
"DeliveryEventInput": {
"type": "object", "required": ["id", "provider", "recipient", "status", "occurredAt"],
"properties": {
"id": { "type": "string" }, "provider": { "type": "string" }, "queueId": { "type": "string" }, "messageId": { "type": "string" }, "rfcMessageId": { "type": "string" },
"recipient": { "type": "string", "format": "email" }, "status": { "type": "string", "enum": ["delivered", "bounced", "complained", "rejected", "deferred"] },
"reason": { "type": "string" }, "occurredAt": { "type": "string", "format": "date-time" }
}
},
"Error": { "type": "object", "required": ["error"], "properties": { "error": { "type": "string" } } }
}
}
}