feat(api): 升级开放 API 并支持投递回调
- 为 `/api/open/v1` 引入 Token scope、分页游标、幂等发信、发送事件与重试/取消能力。 - 新增投递事件签名回调与状态 webhook outbox,补充相关配置、迁移和测试。 - 同步更新 Web 端 API 类型、个人中心 Token 权限管理,以及中英文文档和 OpenAPI 契约。
This commit is contained in:
@@ -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`.
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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` 的证书。
|
||||
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
|
||||
|
||||
## 开放 API
|
||||
|
||||
外部系统应使用版本化的 `/api/open/v1` 接口和带 scope 的 API Token。详细说明见 [API 文档](docs/API.md),机器可读契约见 [OpenAPI 3.1](docs/openapi.json)。发信支持幂等键;最终投递事件可通过签名入口写入,全部状态变化也可通过可靠的签名 webhook outbox 主动推送。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```bash
|
||||
|
||||
@@ -964,7 +964,7 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
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"))
|
||||
return
|
||||
}
|
||||
@@ -1009,7 +1009,7 @@ func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
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"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,9 +15,24 @@ import (
|
||||
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
@@ -43,6 +60,7 @@ func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Scopes json.RawMessage `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -66,6 +84,20 @@ func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
|
||||
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")
|
||||
token := "lq_" + randomToken()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
@@ -73,8 +105,8 @@ func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, now, now); err != nil {
|
||||
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, jsonEncode(scopes), now, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create api token")
|
||||
return
|
||||
}
|
||||
@@ -97,6 +129,7 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
Name *string `json:"name"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -142,8 +175,16 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,updated_at=? WHERE id=? AND user_id=?`,
|
||||
name, expiresValue, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
|
||||
scopes := current.Scopes
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update api token")
|
||||
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) {
|
||||
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)
|
||||
return scanAPIToken(row)
|
||||
}
|
||||
@@ -186,18 +227,44 @@ func scanAPIToken(row apiTokenScanner) (APIToken, error) {
|
||||
var item APIToken
|
||||
var lastUsed, expires sql.NullString
|
||||
var disabled int
|
||||
var created, updated string
|
||||
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &created, &updated); err != nil {
|
||||
var scopesJSON, created, updated string
|
||||
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &scopesJSON, &created, &updated); err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.LastUsedAt = nullableTime(lastUsed)
|
||||
item.ExpiresAt = nullableTime(expires)
|
||||
item.Disabled = intBool(disabled)
|
||||
item.Scopes = jsonDecodeSlice(scopesJSON)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
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) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
+102
-11
@@ -82,6 +82,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -171,9 +172,20 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
scopes_json TEXT NOT NULL DEFAULT '["*"]',
|
||||
created_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_hash ON api_tokens(token_hash)`,
|
||||
`CREATE TABLE IF NOT EXISTS system_settings (
|
||||
@@ -327,6 +339,45 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
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, ¬null, &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 {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
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) {
|
||||
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)
|
||||
if localPart == "" {
|
||||
return "", errors.New("invalid local part")
|
||||
@@ -1207,7 +1307,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
status = "active"
|
||||
}
|
||||
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
|
||||
}
|
||||
address := localPart + "@" + domain
|
||||
@@ -1215,15 +1315,9 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
displayName = address
|
||||
}
|
||||
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
id := newID("mbx")
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1234,9 +1328,6 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
@@ -24,6 +28,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -222,6 +227,10 @@ type testClient struct {
|
||||
}
|
||||
|
||||
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()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
@@ -241,6 +250,9 @@ func (c *testClient) do(method, path string, body any, out any) int {
|
||||
if 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return createTestAPITokenWithScopes(t, client, name, nil)
|
||||
}
|
||||
|
||||
func createTestAPITokenWithScopes(t *testing.T, client *testClient, name string, scopes []string) string {
|
||||
t.Helper()
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
Item APIToken `json:"item"`
|
||||
}
|
||||
if code := client.do("POST", "/api/me/api-tokens", map[string]string{"name": name}, &resp); code != http.StatusCreated {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
a := newTestApp(t)
|
||||
stopTestWorkers(a)
|
||||
|
||||
@@ -51,6 +51,10 @@ type Config struct {
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
DeliveryWebhookSecret string
|
||||
StatusWebhookURL string
|
||||
StatusWebhookSecret string
|
||||
StatusWebhookAllowPrivateHosts bool
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@@ -99,6 +103,10 @@ func LoadConfig() Config {
|
||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
||||
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,7 +2,11 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -14,7 +18,14 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
return
|
||||
@@ -33,7 +44,13 @@ func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
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) {
|
||||
@@ -121,8 +138,15 @@ func (a *App) handleOpenAPIDeleteDomain(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
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
return
|
||||
@@ -141,7 +165,13 @@ func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request)
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
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) {
|
||||
@@ -185,16 +215,31 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
||||
if displayName == "" {
|
||||
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 {
|
||||
respondMailboxOwnerError(w, err)
|
||||
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 {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||
return
|
||||
}
|
||||
mailbox, err := a.mailboxByID(r.Context(), mailboxID)
|
||||
if err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
var req mailComposeInput
|
||||
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")
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -345,6 +459,10 @@ func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
|
||||
@@ -383,7 +503,11 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
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"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
@@ -399,11 +523,20 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
if cursorReceivedAt != "" {
|
||||
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
|
||||
WHERE `+where+`
|
||||
ORDER BY m.received_at DESC LIMIT ? OFFSET ?`, args...)
|
||||
WHERE ` + where + `
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
@@ -425,7 +558,8 @@ func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Reques
|
||||
nextCursor := ""
|
||||
if len(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})
|
||||
}
|
||||
@@ -462,6 +596,7 @@ type openAPISendStatus struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
QueueStatus string `json:"queueStatus,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
@@ -475,13 +610,27 @@ type openAPISendStatus struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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 {
|
||||
status := item.Status
|
||||
if status == sendQueueStatusDelivered {
|
||||
status = "relayed"
|
||||
}
|
||||
return openAPISendStatus{
|
||||
ID: item.ID,
|
||||
ID: firstNonEmpty(item.SentMessageID, item.ID),
|
||||
QueueID: item.ID,
|
||||
Status: item.Status,
|
||||
Status: status,
|
||||
QueueStatus: item.Status,
|
||||
MessageID: item.SentMessageID,
|
||||
RFCMessageID: item.MessageID,
|
||||
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 {
|
||||
recipients := append(append([]string{}, msg.To...), msg.CC...)
|
||||
recipients = append(recipients, msg.BCC...)
|
||||
@@ -521,12 +803,19 @@ func timePtr(t time.Time) *time.Time {
|
||||
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)
|
||||
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
|
||||
}
|
||||
if intBool(disabled) {
|
||||
return "", errors.New("owner user is disabled")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
email := normalizeEmail(ownerEmail)
|
||||
@@ -537,32 +826,21 @@ func (a *App) resolveMailboxOwner(r *http.Request, userID, ownerEmail, address,
|
||||
return "", errors.New("invalid owner email")
|
||||
}
|
||||
var existing string
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE email=? AND disabled=0`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
return a.createMailboxOwnerUser(r, email, displayName, password)
|
||||
}
|
||||
|
||||
func (a *App) createMailboxOwnerUser(r *http.Request, email, displayName, password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
userID := newID("usr")
|
||||
userID = newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(hash), 0, now, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", passwordHash, 0, now, now)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
const apiTokenScopesContextKey contextKey = "api_token_scopes"
|
||||
|
||||
func (a *App) Router() http.Handler {
|
||||
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.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAPIToken)
|
||||
r.With(a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/open/domains", a.handleOpenAPIListDomains)
|
||||
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.Post("/open/v1/delivery-events", a.handleOpenAPIDeliveryWebhook)
|
||||
r.Route("/open", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
r.Route("/open/v1", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
@@ -179,6 +167,37 @@ func (a *App) Router() http.Handler {
|
||||
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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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("Vary", "Origin")
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := a.authenticateAPIToken(r)
|
||||
user, scopes, err := a.authenticateAPIToken(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "api token required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
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 {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
@@ -250,33 +284,37 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
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)
|
||||
if token == "" {
|
||||
return nil, errors.New("no api token")
|
||||
return nil, nil, errors.New("no api token")
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
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
|
||||
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 disabled, twoFactorEnabled int
|
||||
var created string
|
||||
if err := row.Scan(&tokenID, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
return nil, err
|
||||
if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
return nil, nil, errors.New("disabled")
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
|
||||
@@ -365,10 +365,26 @@ func (a *App) recordSendAudit(ctx context.Context, event, status string, in send
|
||||
if source == "" {
|
||||
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)
|
||||
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))
|
||||
id := newID("audit")
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -29,10 +29,25 @@ type APIToken struct {
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Scopes []string `json:"scopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -51,7 +51,7 @@ export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number
|
||||
export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; createdAt: string; updatedAt: string }
|
||||
export type 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 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 }
|
||||
|
||||
@@ -40,8 +40,8 @@ export const api = {
|
||||
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }),
|
||||
changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }),
|
||||
apiTokens: () => request<ListResponse<APIToken>>("/api/me/api-tokens"),
|
||||
createApiToken: (payload: { name: string; expiresAt?: string }) => request<{ token: string; item: APIToken }>("/api/me/api-tokens", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
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; 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" }),
|
||||
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 }) }),
|
||||
|
||||
@@ -148,12 +148,12 @@ export function ProfilePage() {
|
||||
onError: (error) => toast({ title: "关闭失败", description: error.message }),
|
||||
})
|
||||
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 },
|
||||
onError: (error) => toast({ title: "创建失败", description: error.message }),
|
||||
})
|
||||
const updateApiToken = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: { name?: string; expiresAt?: string; disabled?: boolean } }) => api.updateApiToken(id, payload),
|
||||
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 已更新" }) },
|
||||
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 [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)), [])
|
||||
|
||||
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 expiresAt = dateInputToISOString(String(form.get("expiresAt") || ""))
|
||||
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)
|
||||
target.reset()
|
||||
setScopes(["messages:send", "messages:read"])
|
||||
} catch {
|
||||
// Mutation-level error handling already shows the toast.
|
||||
}
|
||||
@@ -1047,14 +1057,22 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end" onSubmit={submit}>
|
||||
<Field label="名称">
|
||||
<Input name="name" required maxLength={80} placeholder="billing-system" />
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end">
|
||||
<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 label="到期日期">
|
||||
<Input name="expiresAt" type="date" defaultValue={defaultExpiresAt} min={dateInputValue(new Date())} required />
|
||||
</Field>
|
||||
<Button disabled={pending}>{pending ? "创建中..." : "创建 Token"}</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1076,8 +1094,12 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
<span>过期:{item.expiresAt ? formatDateTime(item.expiresAt) : "未设置"}</span>
|
||||
<span>最后使用:{item.lastUsedAt ? formatDateTime(item.lastUsedAt) : "从未使用"}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(item.scopes || ["*"]).map((scope) => <Badge key={scope} variant="outline">{scope}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
<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="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>
|
||||
@@ -1088,6 +1110,24 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet
|
||||
</CardContent>
|
||||
</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()} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -97,6 +97,16 @@ LANQIN_SMTP_PASSWORD=
|
||||
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 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 证书。
|
||||
LANQIN_SUBMISSION_ADDR=
|
||||
LANQIN_SUBMISSION_TLS_ADDR=
|
||||
|
||||
@@ -174,6 +174,24 @@ LANQIN_SMTP_PORT=25
|
||||
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`。
|
||||
|
||||
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
|
||||
|
||||
+96
-41
@@ -1,8 +1,12 @@
|
||||
# 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
|
||||
|
||||
@@ -28,6 +32,7 @@ The API uses standard HTTP status codes:
|
||||
| `401 Unauthorized` | Missing or invalid API token / 缺少或无效的 API Token |
|
||||
| `403 Forbidden` | Token lacks required permissions / Token 缺少所需权限 |
|
||||
| `404 Not Found` | Resource does not exist / 资源不存在 |
|
||||
| `409 Conflict` | Idempotency key conflict or concurrent status change / 幂等键冲突或状态并发变化 |
|
||||
| `429 Too Many Requests` | Rate limit exceeded / 超过频率限制 |
|
||||
| `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。
|
||||
|
||||
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
|
||||
|
||||
All Open API endpoints require an API token with appropriate permissions and role requirements:
|
||||
|
||||
所有 Open API 接口都需要具备相应权限和角色的 API Token:
|
||||
|
||||
| Endpoint 接口 | Required Permission 所需权限 | Required 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 |
|
||||
| `POST /api/open/domains` | `admin.domains.create` | 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 |
|
||||
| `POST /api/open/domains/{id}` | `admin.domains.update` | admin |
|
||||
| `DELETE /api/open/domains/{id}` | `admin.domains.delete` | 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 |
|
||||
| Endpoint group | Required scope | Role |
|
||||
|---|---|---|
|
||||
| Domains | `domains:read` or `domains:write` | admin |
|
||||
| Mailboxes | `mailboxes:read` or `mailboxes:write` | admin |
|
||||
| DNS | `dns:read` or `dns:check` | admin |
|
||||
| Aliases | `aliases:read` or `aliases:write` | admin |
|
||||
| Send / status / messages | `messages:send`, `messages:read`, or `messages:manage` | user or admin |
|
||||
|
||||
**Notes:**
|
||||
- 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
|
||||
|
||||
```http
|
||||
GET /api/open/domains
|
||||
GET /api/open/v1/domains
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -182,7 +192,7 @@ Authorization: Bearer lq_xxx
|
||||
### Create domain
|
||||
|
||||
```http
|
||||
POST /api/open/domains
|
||||
POST /api/open/v1/domains
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -221,7 +231,7 @@ Content-Type: application/json
|
||||
### Get domain
|
||||
|
||||
```http
|
||||
GET /api/open/domains/{id}
|
||||
GET /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -234,7 +244,7 @@ Authorization: Bearer lq_xxx
|
||||
### Update domain status
|
||||
|
||||
```http
|
||||
POST /api/open/domains/{id}
|
||||
POST /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -262,7 +272,7 @@ Content-Type: application/json
|
||||
### Delete domain
|
||||
|
||||
```http
|
||||
DELETE /api/open/domains/{id}
|
||||
DELETE /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -289,7 +299,7 @@ Authorization: Bearer lq_xxx
|
||||
### List mailboxes
|
||||
|
||||
```http
|
||||
GET /api/open/mailboxes
|
||||
GET /api/open/v1/mailboxes
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -323,7 +333,7 @@ Authorization: Bearer lq_xxx
|
||||
### Create mailbox
|
||||
|
||||
```http
|
||||
POST /api/open/mailboxes
|
||||
POST /api/open/v1/mailboxes
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -370,7 +380,7 @@ Content-Type: application/json
|
||||
### Get mailbox
|
||||
|
||||
```http
|
||||
GET /api/open/mailboxes/{id}
|
||||
GET /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -383,7 +393,7 @@ Authorization: Bearer lq_xxx
|
||||
### Update mailbox
|
||||
|
||||
```http
|
||||
POST /api/open/mailboxes/{id}
|
||||
POST /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -408,7 +418,7 @@ All fields are optional. Omitted (or empty / non-positive) fields keep their cur
|
||||
### Delete mailbox
|
||||
|
||||
```http
|
||||
DELETE /api/open/mailboxes/{id}
|
||||
DELETE /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
@@ -433,8 +443,9 @@ Authorization: Bearer lq_xxx
|
||||
## Send Mail
|
||||
|
||||
```http
|
||||
POST /api/open/send
|
||||
POST /api/open/v1/send
|
||||
Authorization: Bearer lq_xxx
|
||||
Idempotency-Key: invoice-2026-0001
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -505,7 +516,7 @@ Total attachment size is limited by the sender's permission group (`maxAttachmen
|
||||
|
||||
| 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` 时不返回 |
|
||||
| `status` | Delivery status, see values below / 投递状态,见下方取值 |
|
||||
| `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`。
|
||||
|
||||
`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:
|
||||
|
||||
当前状态取值:
|
||||
@@ -527,18 +542,22 @@ Current status values:
|
||||
- `accepted`: message was accepted and stored, but no SMTP queue item exists.
|
||||
- `queued`: queued for SMTP delivery.
|
||||
- `sending`: currently being delivered.
|
||||
- `delivered`: SMTP delivery succeeded.
|
||||
- `relayed`: the configured upstream SMTP server accepted the message; this is not final recipient delivery.
|
||||
- `failed`: delivery failed and may be retried.
|
||||
- `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>
|
||||
|
||||
- `accepted`:邮件已被接受并存储,但没有 SMTP 队列项。
|
||||
- `queued`:已进入 SMTP 投递队列。
|
||||
- `sending`:正在投递中。
|
||||
- `delivered`:SMTP 投递成功。
|
||||
- `relayed`:配置的上游 SMTP 已接受邮件,但这不代表最终收件成功。
|
||||
- `failed`:投递失败,可能会重试。
|
||||
- `canceled`:投递已取消。
|
||||
- `delivered`、`bounced`、`complained`、`rejected`、`deferred`:每个收件人的最终供应商或 DSN 事件。
|
||||
- `partial`:不同收件人的最终状态不同,或当前只收到了部分收件人的事件。
|
||||
|
||||
**Error cases:**
|
||||
|
||||
@@ -550,31 +569,31 @@ Current status values:
|
||||
| `429` | SMTP send rate limit exceeded / 超过 SMTP 发信频率限制 |
|
||||
| `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
|
||||
|
||||
```http
|
||||
GET /api/open/send/{id}
|
||||
GET /api/open/v1/send/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**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`。
|
||||
|
||||
**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
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -586,7 +605,7 @@ Query parameters:
|
||||
|
||||
- `folder`: folder name. Defaults to `Inbox`; use `all` for all folders.
|
||||
- `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.
|
||||
|
||||
<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` 为空字符串;否则它是下次请求应作为 `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 邮箱。
|
||||
|
||||
## 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。
|
||||
|
||||
@@ -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" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user