平台模式(租户)
平台模式让一个 SpacePost 账户可以为你的众多客户发信。每个客户就是一个租户。当你构建的产品要为其他企业发送邮件时,请使用它,例如开票或客服类 SaaS:每个客户连接自己的域名,且某一个恶意用户不能连累其他所有人。
如果你只发送自己产品的邮件,就不需要租户。
租户能带来什么
Section titled “租户能带来什么”| 相互隔离的信誉 | 每个租户都有自己独立的 Amazon SES 租户。某个客户的投诉和退信只会体现在该客户身上。 |
| 独立的信誉防护 | 相同的自动暂停阈值(该租户最近 1,000 封邮件中退信率超过 5% 或投诉率超过 0.2%,并有最小样本量要求)只会暂停该租户。 |
| 你的账户保持干净 | 租户的邮件不会计入你自己团队的信誉检查,因此某个不良客户无法导致你的账户或其他客户被暂停。 |
| 暂停开关 | 你可以自行暂停和恢复任何租户,并附上原因。 |
| 每日上限 | 可选的 daily_limit,按每个租户、每个 UTC 日的收件人数计算。 |
| 你自己的 ID | 用你自己的 external_id 来指代租户,无需保存我们的 id。 |
| 归属标识 | 邮件、域名和 Webhook 事件都带有 tenant_id。 |
租户共享你的套餐:每个租户的收件人都会计入你的每月配额、团队的每日限额和你的 API 速率限制。内容过滤同样适用于租户的邮件,即使该账户本身原本可以豁免,其租户也不例外。
POST /tenants 接受 name,以及可选的 external_id(在你的账户内唯一)和 daily_limit。所有租户端点都需要 full_access API 密钥。
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
// One tenant per customer. `externalId` is your own customer id and works anywhere a tenant id does.const { data: tenant, error } = await spacepost.tenants.create({ name: "Acme Inc", externalId: "cust_123", dailyLimit: 500, // optional cap, in recipients per UTC day});if (error) throw new Error(`${error.name}: ${error.message}`);console.log(tenant.id, tenant.status); // "active"
// Optional: give the tenant its own sending domain. Mail from it is attributed to the tenant automatically.const { data: domain } = await spacepost.domains.create({ name: "mail.acme-inc.com", tenantId: "cust_123" });console.log(domain?.tenant_id === tenant.id); // truecurl -X POST https://api.spacepost.dev/tenants \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500 }'spacepost tenants create --name "Acme Inc" --external-id cust_123 --daily-limit 500{ "object": "tenant", "id": "0192e1c4-5555-7000-8000-0000000000cc", "name": "Acme Inc", "external_id": "cust_123", "daily_limit": 500, "status": "active", "paused_reason": null, "created_at": "2026-10-23T14:02:09.512Z"}external_id
Section titled “external_id”在所有指代租户的地方(/tenants/:ref、邮件和域名上的 tenant_id、spacepost tenants get <ref>),你既可以传我们的 id,也可以传你的 external_id。只需保存一次你自己的客户 id,就再也不用维护映射表。Webhook 始终携带我们的租户 id;如果你需要 external_id,请用 GET /tenants/{id} 查询。
域名:租户自有或团队级
Section titled “域名:租户自有或团队级”| 租户自有域名 | 团队级域名 | |
|---|---|---|
| 创建方式 | 在 POST /domains 中带 tenant_id |
不带 tenant_id 的 POST /domains |
| 典型所有者 | 客户自己的域名(mail.acme-inc.com) |
你的域名(yourapp.com) |
| 归属 | 自动:从它发出的每封邮件都归属于该租户 | 你在每封邮件上传入 tenant_id,否则邮件视为你自己的 |
| 谁可以使用它发信 | 仅该租户 | 你自己的邮件,以及任何租户 |
如果邮件指定的 tenant_id 与其域名所属的租户不同,请求会以 422 validation_error 失败。当 from 域名已属于某个租户时,tenant_id 可以省略。
添加租户自有域名的方式与添加其他域名完全相同,只多一个字段,然后在客户的 DNS 服务商处添加 DNS 记录。参见添加与验证。
curl -X POST https://api.spacepost.dev/domains \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "mail.acme-inc.com", "tenant_id": "cust_123" }'spacepost domains add mail.acme-inc.com --tenant cust_123代表租户发送
Section titled “代表租户发送”import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
// From a team-level domain (yours), name the tenant on each email.const { data, error } = await spacepost.emails.send({ from: "Acme Inc via YourApp <invoices@yourapp.com>", to: "buyer@example.com", subject: "Invoice #1042", html: "<p>Your invoice is attached.</p>", tenantId: "cust_123", // our tenant id or your external_id});
if (error?.name === "sending_paused") { console.warn("This customer is paused:", error.message);} else if (error?.name === "daily_quota_exceeded") { console.warn("This customer hit its daily cap; retry tomorrow.");} else if (error) { throw new Error(`${error.name}: ${error.message}`);} else { console.log("Queued", data.id);}
// batch.send accepts tenantId per email.await spacepost.batch.send([ { from: "invoices@yourapp.com", to: "a@example.com", subject: "Hello A", text: "Hi A", tenantId: "cust_123" }, { from: "invoices@yourapp.com", to: "b@example.com", subject: "Hello B", text: "Hi B", tenantId: "cust_456" },]);curl -X POST https://api.spacepost.dev/emails \ -H "Authorization: Bearer $SPACEPOST_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme Inc via YourApp <invoices@yourapp.com>", "to": ["buyer@example.com"], "subject": "Invoice #1042", "html": "<p>Your invoice is attached.</p>", "tenant_id": "cust_123" }'POST /emails/batch 接受每一项上的 tenant_id。
官方的 resend 包无需修改即可配合 SpacePost 使用。通过 RESEND_BASE_URL=https://api.spacepost.dev 或 new Resend(key, { baseUrl }) 将其指向我们。
最简单的方式:使用租户自有域名发信。 租户会根据 from 域名推断出来,因此你现有的 resend.emails.send 调用完全不需要修改。
使用团队级域名时,请用 resend.post。 Resend SDK 会根据固定的字段列表构建请求体,因此传给 resend.emails.send、resend.batch.send 或 resend.domains.create 的额外 tenant_id 会在到达 SpacePost 之前被悄悄丢弃。resend.post 会原样发送你提供的请求体,因此请配合 snake_case 字段使用它:
import { Resend } from "resend";
const resend = new Resend(process.env.SPACEPOST_API_KEY, { baseUrl: "https://api.spacepost.dev" });
const { data, error } = await resend.post<{ id: string }>("/emails", { from: "Acme Inc via YourApp <invoices@yourapp.com>", to: ["buyer@example.com"], subject: "Invoice #1042", html: "<p>Your invoice is attached.</p>", tenant_id: "cust_123",});与 resend.emails.send 不同,resend.post 不会转换驼峰命名:请写成 reply_to 和 scheduled_at。resend.post("/domains", { name, tenant_id }) 和 resend.post("/emails/batch", [...]) 同样适用。
spacepost send --from invoices@yourapp.com --to buyer@example.com \ --subject "Invoice #1042" --text "Your invoice is attached." --tenant cust_123暂停、恢复与上限
Section titled “暂停、恢复与上限”import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: page } = await spacepost.tenants.list({ limit: 20 });for (const t of page?.data ?? []) console.log(t.name, t.external_id, t.status, t.paused_reason);
await spacepost.tenants.get("cust_123"); // by external_id or our idawait spacepost.tenants.update({ id: "cust_123", dailyLimit: 1000 }); // dailyLimit: null removes the cap
await spacepost.tenants.pause({ id: "cust_123", reason: "Investigating spam reports" });
const { error } = await spacepost.tenants.unpause("cust_123");if (error?.statusCode === 403) console.warn("Paused by the reputation guard; contact support to review it.");
// Domains first, then the tenant (422 while it still has domains).const { data: domains } = await spacepost.domains.list({ limit: 100 });for (const d of domains?.data ?? []) if (d.tenant_id) console.log(d.name, "belongs to", d.tenant_id);await spacepost.tenants.remove("cust_123");# Pause (the body is optional)curl -X POST https://api.spacepost.dev/tenants/cust_123/pause \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "reason": "Investigating spam reports" }'
# Resumecurl -X POST https://api.spacepost.dev/tenants/cust_123/unpause -H "Authorization: Bearer $SPACEPOST_API_KEY"
# Change the cap (null removes it)curl -X PATCH https://api.spacepost.dev/tenants/cust_123 \ -H "Authorization: Bearer $SPACEPOST_API_KEY" -H "Content-Type: application/json" \ -d '{ "daily_limit": 1000 }'
# Delete (422 while the tenant still has domains)curl -X DELETE https://api.spacepost.dev/tenants/cust_123 -H "Authorization: Bearer $SPACEPOST_API_KEY"spacepost tenants listspacepost tenants get cust_123spacepost tenants pause cust_123 --reason "Investigating spam reports"spacepost tenants unpause cust_123spacepost tenants rm cust_123| 情形 | 结果 |
|---|---|
| 为已暂停的租户发送 | 403 sending_paused。已入队的邮件会以 email.failed 事件失败。 |
租户超出其 daily_limit |
429 daily_quota_exceeded(每天在 UTC 00:00 重置)。该请求中的任何邮件都不会发送。 |
tenant_id 与域名所属租户冲突 |
422 validation_error |
未知的 tenant_id |
404 not_found |
| 恢复被信誉防护暂停的租户 | 403 invalid_access,除非你的团队是 trusted 或 internal。请在修复原因后联系 support@spacepost.dev。你自己执行的暂停随时都可以解除。 |
| 删除仍有域名的租户 | 422 validation_error。请先删除它的域名。 |
Webhooks
Section titled “Webhooks”你的常规 Webhook 同样会收到租户邮件的事件,其中 data.tenant_id 为我们的租户 id。没有租户的邮件则没有 tenant_id 键。
{ "type": "email.bounced", "created_at": "2026-10-23T14:02:12.000Z", "data": { "email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10", "from": "Acme Inc via YourApp <invoices@yourapp.com>", "to": ["nobody@example.com"], "subject": "Invoice #1042", "created_at": "2026-10-23T14:02:09.512Z", "tenant_id": "0192e1c4-5555-7000-8000-0000000000cc", "bounce": { "type": "Permanent", "subType": "General", "message": "smtp; 550 5.1.1 user unknown" } }}import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
export async function handle(request: Request): Promise<Response> { const payload = await request.text(); try { const event = await spacepost.webhooks.verify({ payload, headers: request.headers, secret: process.env.SPACEPOST_WEBHOOK_SECRET!, }); // `tenant_id` is our tenant id (not your external_id). It is absent on the team's own mail. const tenantId = event.data.tenant_id; if (event.type === "email.bounced" && tenantId) { console.log(`Bounce for tenant ${tenantId}: ${event.data.bounce?.subType}`); } return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); }}GET /emails/{id} 和 GET /domains/{id} 也会返回 tenant_id(或 null),方便你在自己的数据库中对账。
SaaS 集成检查清单
Section titled “SaaS 集成检查清单”- 客户注册时创建一个租户,以你的客户 id 作为
external_id。 - 为每个客户做出选择:使用其自己的域名(
POST /domains上带tenant_id),或使用你的共享域名(每次发送都带tenant_id)。 - 为新客户设置保守的
daily_limit,待其证明自己可靠后再逐步提高。 - 订阅
email.bounced、email.complained和email.failed,并在你的管理界面中展示与tenant_id相关的问题。 - 一旦发现滥用,立即自行暂停该客户。暂停即时生效,并且可以撤销。