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

- 为 `/api/open/v1` 引入 Token scope、分页游标、幂等发信、发送事件与重试/取消能力。
- 新增投递事件签名回调与状态 webhook outbox,补充相关配置、迁移和测试。
- 同步更新 Web 端 API 类型、个人中心 Token 权限管理,以及中英文文档和 OpenAPI 契约。
This commit is contained in:
LanQin_
2026-07-10 10:47:58 +08:00
parent 25f54bc42f
commit 47f782a03c
20 changed files with 1891 additions and 162 deletions
+96 -41
View File
@@ -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。