第 15a 章 客户端、Groupware & CLI 管理后台:#/delivery

Delivery Insight:通过 REST API、实时流、Webhook 和小组件提供每个收件人的投递状态

Delivery Insight 向通过 nextxch 发送邮件 (POST /api/v1/send) 的应用显示其自身邮件直至每个收件人的实时状态:已投递、传输中、正在重试(含原因和下次尝试时间)、已退信、已抑制或已过期。它包括状态 API、批次摘要、实时流 (Server-Sent Events)、签名 Webhook、队列干预(停止、重试、重新安排、重定向)、带按应用抑制列表的退信分类,以及可嵌入的状态小组件。基础 URL 为 https://<server>/api/v1,机器可读的 OpenAPI 3.1 描述位于 /api/v1/openapi.json。属于公共 API,因此自 Business 版起提供。

运维提示与架构说明

隐私:Delivery Insight 仅报告邮件服务器之间发生的情况。没有打开跟踪和点击跟踪,没有跟踪像素,也不会改写链接。元数据只返回给您,绝不会写入日志;用于流的事件保留 72 小时,发送记录和历史保留 30 天。用户密钥只能看到其账户的邮件,租户密钥只能看到其自己发送的内容;对他人的 id 的请求与未知 id 一样返回 404。

1. 快速入门:创建密钥、发送、读取状态

管理后台 > API 与集成 > 集成密钥:作用域 mail.send 和 delivery.read(Webhook 另需 webhooks.manage,干预和抑制列表另需 delivery.write),并为该应用设置专用的发件人地址。密钥 smk_... 仅显示一次。也可以使用从 POST /api/v1/oauth/token 获取的 OAuth 2.0 令牌(客户端凭据,有效期 1 小时)。

参考 / 配置
# send.json
{
  "from": "noreply@drinklist.example",
  "to": ["anna@gmx.de", "ben@web.de"],
  "subject": "Drink list November",
  "text": "Hello, the list is ready.",
  "batchId": "drinklist-2026-11",
  "tags": ["monthly", "drinklist"],
  "metadata": { "listId": "2026-11" },
  "idempotencyKey": "drinklist-2026-11-run-1"
}

curl -sS https://mail.example.com/api/v1/send \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" -H "Content-Type: application/json" -d @send.json
# Answer: sendId per call, recipientId per recipient, state queued or scheduled

curl -sS https://mail.example.com/api/v1/sends/0193a0c4-7c1e-7b8a-9f00-3c2d1e0f4a11 -H "Authorization: Bearer $NEXTXCH_API_KEY"
curl -sS https://mail.example.com/api/v1/batches/drinklist-2026-11 -H "Authorization: Bearer $NEXTXCH_API_KEY"

2. 状态 API 和批次摘要

GET /api/v1/sends/{sendId} 返回该发送及其所有收件人(attempts、nextAttemptAtUtc、lastSmtpCode、lastSmtpResponse、targetMx、tls、throttleReason、provider)。GET /api/v1/sends 按从新到旧列出,最远可回溯到保留期(30 天);limit 默认 50,最多 200,使用 cursor = pagination.nextCursor 继续。GET /api/v1/batches/{batchId} 汇总一个批次的所有发送,用于显示进度。

参考 / 配置
GET /api/v1/sends?tag=monthly&status=deferred&batchId=drinklist-2026-11&since=2026-11-10T00:00:00Z&limit=50

GET /api/v1/batches/drinklist-2026-11
{
  "ok": true,
  "data": {
    "batchId": "drinklist-2026-11", "sends": 1, "total": 400,
    "delivered": 371, "inFlight": 4, "retrying": 22, "bounced": 2, "suppressed": 1,
    "expired": 0, "cancelled": 0, "completed": false, "progressPercent": 93.5,
    "nextAttemptAtUtc": "2026-11-10T08:05:02Z",
    "retry": { "total": 22, "byReason": [{ "reason": "rate_limit", "count": 15 }],
               "byProvider": [{ "provider": "GMX / WEB.DE", "count": 15 }] }
  }
}

3. 历史记录和实时流 (SSE),支持 Last-Event-ID

每次状态变化都是一个事件:delivery.queued、delivery.deferred、delivery.delivered、delivery.bounced、delivery.suppressed、delivery.expired、delivery.cancelled 或 delivery.redirected,并带有连续编号 seq。历史记录 GET /api/v1/events 按从旧到新返回(最多 30 天,limit 最多 500)。流 GET /api/v1/events/stream 发送 id、event 和 data;每 5 秒发送一条注释 (: ping) 以保持代理连接。使用 Last-Event-ID 标头(或 ?lastEventId=)可续接最多 72 小时前的事件;服务器建议 retry: 3000。API 密钥不应出现在浏览器中:浏览器中请使用状态小组件。

参考 / 配置
curl -N https://mail.example.com/api/v1/events/stream?batchId=drinklist-2026-11 \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" -H "Last-Event-ID: 1041"

id: 1042
event: delivery.deferred
data: {"id":"evt_3f1c0d9a8b7e6f5a4b3c2d1e","seq":1042,"type":"delivery.deferred","data":{"recipient":"anna@gmx.de","state":"deferred","attempts":1,"nextAttemptAtUtc":"2026-11-10T08:05:02Z","lastSmtpCode":451,"throttleReason":"greylisting","provider":"GMX / WEB.DE"}}

: ping

# History (next page with after=pagination.nextCursor):
GET /api/v1/events?after=1000&batchId=drinklist-2026-11&limit=500

4. 注册 Webhook 并验证签名

POST /api/v1/webhooks(作用域 webhooks.manage,投递事件另需 delivery.read)返回 201 以及签名密钥 whsec_...,该密钥仅在此响应中出现。投递事件必须明确指定,* 不包含它们。batchSeconds 设为 1 到 60 时会将事件合并为一个 JSON 数组(X-Nextxch-Event: delivery.batch,最多 500 个);设为 0 时逐个发送。签名:X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + raw body))。请始终使用原始请求体,以恒定时间进行比较,并拒绝旧的时间戳(超过 5 分钟)。请在 10 秒内以 2xx 响应;否则 nextxch 会使用相同的事件 id 在 30 秒、2 分钟、10 分钟、1 小时和 6 小时后重试。

参考 / 配置
POST /api/v1/webhooks
{"url":"https://drinklist.example/hooks/nextxch",
 "events":["delivery.deferred","delivery.delivered","delivery.bounced","delivery.expired"],
 "batchSeconds":10}
# Header: X-Nextxch-Event, X-Nextxch-Event-Id, X-Nextxch-Delivery, X-Nextxch-Webhook-Id, X-Nextxch-Timestamp, X-Nextxch-Signature
# Log: GET /api/v1/webhooks/{id}/deliveries · Test: POST /api/v1/webhooks/{id}/test

// C#
static bool VerifyWebhook(string secret, string timestamp, string signature, byte[] rawBody)
{
    if (!long.TryParse(timestamp, out long ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    string expected = "v1=" + Convert.ToHexStringLower(hmac.ComputeHash([.. Encoding.ASCII.GetBytes(ts + "."), .. rawBody]));
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature.Trim()));
}

// PHP
function verify_webhook(string $secret, string $timestamp, string $signature, string $rawBody): bool
{
    if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) return false;
    return hash_equals('v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), trim($signature));
}

# Python
def verify_webhook(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature.strip())

// JavaScript (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, timestamp, signature, rawBody) {
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = Buffer.from('v1=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'));
  const given = Buffer.from(String(signature).trim());
  return expected.length === given.length && timingSafeEqual(expected, given);
}

5. 干预:停止、重试、重新安排、重定向

作用域 delivery.write。仅影响未完成的收件人(scheduled、queued、deferred);其他收件人会被跳过,原因为 final、in_flight 或 not_in_queue。重试和重新安排按队列项生效,同一队列项的所有未完成收件人会一起变动。重新安排最多可提前 30 天,批次调用最多 10,000 个收件人。重定向会创建一个新收件人(事件 delivery.redirected,一条链中最多 3 次重定向,计入出站限制)。每次干预都会记录在安全审计日志中(DeliveryInsight.Cancel、.Retry、.Reschedule、.Redirect),包含数量和 id,但绝不包含地址。

参考 / 配置
POST /api/v1/sends/{sendId}/cancel                                   # ohne Body / no body
POST /api/v1/sends/{sendId}/retry
POST /api/v1/batches/{batchId}/reschedule      {"sendAtUtc":"2026-11-11T06:00:00Z"}
POST /api/v1/sends/{sendId}/recipients/{recipientId}/redirect   {"to":"buchhaltung@kunde.example"}

# Antwort / answer
{"action":"retry","applied":3,"skipped":1,
 "recipients":[{"recipientId":"...","email":"anna@gmx.de","state":"queued","applied":true,"reason":null,"newRecipientId":null}]}

# Fehler / errors: 400 INVALID_TIME, INVALID_ADDRESS, SAME_ADDRESS, REDIRECT_ONE_RECIPIENT
#                  409 ALREADY_DELIVERED, IN_FLIGHT, REDIRECT_LIMIT, ADDRESS_SUPPRESSED · 429 OUTBOUND_LIMIT

6. 按应用的抑制列表

每个发送应用(API 密钥;对于登录后的发送则为账户)都有自己的抑制列表。POST /api/v1/send 会排除被抑制的收件人,并以状态 suppressed 记录;如果全部被抑制,API 返回 422 ALL_RECIPIENTS_SUPPRESSED。条目的原因为 hard_bounce、policy、complaint、unsubscribe 或 manual。使用 delivery.read 读取,使用 delivery.write 更改;添加和解除均记录在审计日志中。Reputation Shield 的全服务器抑制列表是独立的。

参考 / 配置
GET    /api/v1/suppressions?q=gmx.de&reason=hard_bounce&limit=100
POST   /api/v1/suppressions   {"emails":["alt@kunde.example"],"expiresAtUtc":"2027-01-01T00:00:00Z","note":"Kunde gekündigt"}
DELETE /api/v1/suppressions/alt@kunde.example

# Eintrag / entry
{"email":"bad@hard.example","reason":"hard_bounce","bounceClass":"mailbox_unknown",
 "diagnostic":"550 5.1.1 User unknown","sendId":"...","recipientId":"...",
 "createdAtUtc":"2026-11-10T08:00:03Z","expiresAtUtc":"2027-11-10T08:00:03Z"}

7. 嵌入状态小组件(小组件令牌、允许的来源)

您的服务器使用 API 密钥(作用域 delivery.read,无登录会话)通过 POST /api/v1/delivery/widget-token 获取小组件令牌:必须且只能指定一个 sendId 或 batchId,ttlSeconds 为 60 到 86400(默认 3600),最多 10 个允许的来源(https;http 仅限 localhost;省略时使用密钥的来源)。令牌 nxwt_... 经过签名、只读,且仅对该发送或批次有效。批次令牌从不显示地址,includeRecipients 仅在使用 sendId 时可用。该小组件是无依赖的 Web 组件(shadow DOM、无 eval、无内联样式),并在严格的 Content-Security-Policy 下运行。响应仅对令牌的来源携带 CORS 标头,否则返回 403 ORIGIN_NOT_ALLOWED。密钥上的来源通过管理 API 的 allowedOrigins 设置。

参考 / 配置
POST /api/v1/delivery/widget-token
{"batchId":"drinklist-2026-11","ttlSeconds":3600,"origins":["https://drinklist.example"]}

<script src="https://mail.example.com/shared/delivery-widget.js" defer></script>
<nextxch-delivery-status token="nxwt_..." batch="drinklist-2026-11" lang="de" theme="auto"></nextxch-delivery-status>

<style>
  nextxch-delivery-status { --nextxch-accent: #2563b0; --nextxch-radius: 12px; }
  @media (prefers-color-scheme: dark) { nextxch-delivery-status { --nextxch-accent: #7fb2f0; } }
</style>
<script>
  const el = document.querySelector('nextxch-delivery-status');
  el.addEventListener('nextxch-delivery-error', async e => {
    if (e.detail.code === 'WIDGET_TOKEN_EXPIRED') el.token = await fetch('/my-app/widget-token').then(r => r.text());
  });
</script>

设置、默认值与最佳实践

设置 / 位置 说明 默认值 生产环境最佳实践
sendId, recipientId 由服务器分配 (UUID)。使用 idempotencyKey 或 Idempotency-Key 标头时,24 小时内的重复请求返回相同的响应和相同的 sendId。服务器自行展开的地址(列表、转发)不会逐个跟踪。 - 请为每次业务运行设置一个 idempotencyKey,以免发送时的网络错误导致重复邮件。
batchId 1 到 100 个字符(字母、数字、. _ : -,以字母或数字开头)。使用相同 batchId 的多次发送构成一个批次;批次 id 按账户区分。 bat_... (vom Server) 有意义的 id(例如 invoices-2026-11)可简化筛选和进度显示。
tags, metadata 最多 10 个标签,每个 1 到 64 个字符(以小写存储,筛选参数 tag=);metadata 为键值对,最大 2 KB,会在每个响应、事件和 Webhook 中返回,绝不写入日志。 - 请勿在元数据中放入机密或不必要的个人数据。
state (Empfängerzustand) scheduled、queued 和 deferred 为未完成状态;delivered、bounced、suppressed、expired 和 cancelled 为最终状态。只要有收件人未完成,发送的状态即为 pending,否则为 completed。 queued 批次摘要中的 progressPercent 统计已达最终状态的收件人;当全部为最终状态时,completed 为 true。
throttleReason 等待期间的原因:greylisting、rate_limit、provider_cooldown、provider_quota、connection、tls_required、temporary。 - 批次摘要中的 retry.byReason 和 retry.byProvider 显示哪个服务商在限流以及何时继续。
bounceClass mailbox_unknown、mailbox_full、spam_rejected、policy、dns_failure、tls_required、temporary。根据文本、增强状态码(例如 x.1.1、x.2.2、x.7.10)判断,后备依据为 4xx/5xx;退信是否为永久性另行说明。 - 请在您自己的应用中将 mailbox_unknown 地址标记为无效,而不是重试。
DeliveryInsight:Verp VERP 退回路径 local+nxb-<token>@domain(由 recipientId 和 HMAC 生成的 34 字符令牌)。可将送达报告 (DSN)、自由文本退信和延迟退信精确匹配到单个收件人。仅适用于本地部分不超过 64 个字符的本地发件人域。 true 请保持开启。未启用 VERP 时,报告仅按 Message-ID、发件人域和收件人进行匹配,且仅限最后一次尝试后的 7 天内。
Sperrliste je Anwendung (automatische Einträge) 永久性 mailbox_unknown 退信(原因 hard_bounce)、重复的永久性策略拒收 (policy)、ARF 投诉(complaint,180 天)以及 RFC 8058 一键退订(unsubscribe,无限期)。由 DMARC、SPF、DKIM、大小或发件方导致的拒收从不计入。 hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage 可通过 DeliveryInsight:HardBounceSuppressionDays、PolicyBounceSuppressAfter 和 PolicySuppressionDays 调整这些期限。
Rate Limits (DeliveryInsight:*) 每个密钥每分钟 300 个请求(按节点计),每个密钥 5 个打开的流;小组件每个令牌每分钟 300 个请求,每个令牌 3 个小组件流,每个密钥 200 个。响应为带 Retry-After 的 429 RATE_LIMITED 或 429 TOO_MANY_STREAMS。 RequestsPerMinute 300 · MaxStreamsPerKey 5 请使用流或 Webhook,而不是以短间隔轮询。
Aufbewahrung (RetentionDays, StreamRetentionHours) 发送记录、收件人和历史保留 30 天,用于续接流的事件保留 72 小时;之后删除。未指定 since 时的查询窗口:最近 24 小时。 30 Tage · 72 h 通过 Webhook 将需要更长期保留的结果复制到您自己的数据库中。
Webmail & Admin 在 Webmail 中,“已发送”文件夹显示您自己通过 API 发送的邮件的投递状态(Free 版:最近 24 小时)。在管理后台中,#/delivery 页面显示所有发送记录,可按账户、密钥、批次和标签筛选(Business 版;租户管理员可查看其租户)。 - 管理后台和 Webmail 中的停止、重试和重定向使用相同的 /api/v1 调用,并以相同方式记录。