跳转到内容

Node.js SDK

@spacepost/node 是一个零依赖、基于 fetch 的客户端,方法名和 { data, error } 返回结构与 Resend SDK 相同。它可在 Node 18+、Bun、Deno 和边缘运行时上运行。

终端窗口
npm install @spacepost/node
# or: bun add @spacepost/node | pnpm add @spacepost/node | yarn add @spacepost/node
client.ts
import { SpacePost } from "@spacepost/node";
// Reads SPACEPOST_API_KEY (and SPACEPOST_BASE_URL) from the environment when no key is passed.
const spacepost = new SpacePost();
// Or configure explicitly, for example in tests or against a self-hosted deployment.
const custom = new SpacePost("sp_live_YOUR_API_KEY", { baseUrl: "https://api.spacepost.dev" });
console.log(spacepost.baseUrl, custom.baseUrl);
构造参数 环境变量回退 默认值
new SpacePost(apiKey) SPACEPOST_API_KEY 无;缺失时会抛出异常
{ baseUrl } SPACEPOST_BASE_URL https://api.spacepost.dev
{ fetch } 不适用 全局 fetch(可为测试或代理注入)
{ userAgent } 不适用 spacepost-node/<version>

每个方法都返回 { data, error } 的 Promise,两者中恰好有一个不为 null。方法在遇到 API 或网络错误时绝不会抛出异常。

const { data, error } = await spacepost.emails.get(id);
if (error) {
// error.name "not_found" | "validation_error" | ...
// error.message human readable
// error.statusCode HTTP status, or null when the request never got a response
return;
}
console.log(data.last_event);

(webhooks.verify 是例外:签名无效时它会抛出异常。)

errors.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
const { data, error } = await spacepost.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>Hello</p>",
});
if (error) {
switch (error.name) {
case "rate_limit_exceeded":
// wait about a second, then retry with the same idempotency key
break;
case "daily_quota_exceeded":
case "monthly_quota_exceeded":
// alert someone: you hit a limit
break;
case "validation_error":
case "invalid_from_address":
// a bug in your request: do not retry
throw new Error(`${error.name}: ${error.message}`);
default:
console.error(error.statusCode, error.name, error.message);
}
} else {
console.log(data.id);
}

请求字段既可以写成驼峰命名(replyTo、scheduledAt、contentType、audienceId、tenantId、externalId、dailyLimit),也可以写成 snake_case;SDK 会自动转换。响应则与 API 发送的内容完全一致(snake_case)。

命名空间 方法
emails send, get, list, update, cancel
batch send
domains create, get, list, verify, update, remove
apiKeys create, list, remove
webhooks create, get, list, update, remove, verify
audiences create, get, list, remove
contacts create, get, list, update, remove
broadcasts create, get, list, update, remove, send
suppressions list, create, remove
tenants create, get, list, update, remove, pause, unpause

send(payload, { idempotencyKey? }), get(id), list({ limit, after, before }), update({ id, scheduledAt }), cancel(id).

emails.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
// send
const { data: sent } = await spacepost.emails.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Hello",
html: "<p>Hello</p>",
scheduledAt: "in 1 hour",
});
if (sent) {
// get: includes html/text and last_event
const { data: email } = await spacepost.emails.get(sent.id);
console.log(email?.last_event); // "scheduled"
// update: reschedule
await spacepost.emails.update({ id: sent.id, scheduledAt: "in 3 hours" });
// cancel
await spacepost.emails.cancel(sent.id);
}
// list: cursor pagination, newest first, bodies omitted
const { data: page } = await spacepost.emails.list({ limit: 10 });
if (page?.has_more) {
const last = page.data[page.data.length - 1]!;
const { data: older } = await spacepost.emails.list({ limit: 10, after: last.id });
console.log(older?.data.length);
}

send 接受发送邮件中的所有字段,包括用于平台模式的 tenantId,以及用于 React Email 的 react(需要可选的对等依赖 @react-email/render;参见 React Email)。附件的 content 可以是 base64 字符串、Buffer 或 Uint8Array。

batch.send(emails, { idempotencyKey? }) 最多发送 100 封邮件(每封邮件可以带有各自的 tenantId),并解析为 { data: { data: [{ id }] } }。参见批量发送。

batch.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data, error } = await spacepost.batch.send(
[
{ from: "Acme <hello@acme.com>", to: ["a@example.com"], subject: "Hi Ada", html: "<p>Hi Ada</p>" },
{ from: "Acme <hello@acme.com>", to: ["b@example.com"], subject: "Hi Bob", html: "<p>Hi Bob</p>" },
],
{ idempotencyKey: "weekly-digest-2026-w43" },
);
if (error) console.error(error);
else console.log(data.data.map((e) => e.id)); // ids in input order
domains.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
const { data: domain } = await spacepost.domains.create({ name: "acme.com", customReturnPath: "send" });
if (domain) {
await spacepost.domains.verify(domain.id);
const { data: fresh } = await spacepost.domains.get(domain.id); // includes records
console.log(fresh?.status, fresh?.records);
await spacepost.domains.update({ id: domain.id, openTracking: true, clickTracking: true });
await spacepost.domains.remove(domain.id);
}
const { data: all } = await spacepost.domains.list(); // no records in lists
console.log(all?.data.map((d) => d.name));

create({ name, region?, customReturnPath?, tenantId? })、get(id)(含 records)、list()、verify(id)、update({ id, openTracking?, clickTracking? })、remove(id)。参见添加与验证。

create({ name, permission?, domainId? }) 返回 { id, token };令牌只显示一次。

api-keys.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
// A key that can only send, and only from one domain.
const { data: key, error } = await spacepost.apiKeys.create({
name: "worker-prod",
permission: "sending_access",
domainId: "0192e1c4-0000-7000-8000-000000000001",
});
if (error) throw new Error(error.message);
console.log(key.token); // sp_live_..., shown only once
const { data: keys } = await spacepost.apiKeys.list();
console.log(keys?.data.map((k) => `${k.name} ${k.prefix}... ${k.permission}`));
await spacepost.apiKeys.remove(key.id);

create、get、list、update、remove 用于管理端点(Webhook)。

create-webhook.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: webhook, error } = await spacepost.webhooks.create({
endpoint: "https://acme.com/webhooks/spacepost",
events: ["email.delivered", "email.bounced", "email.complained"],
});
if (error) throw new Error(`${error.name}: ${error.message}`);
// Store this secret: you need it to verify signatures. It starts with "whsec_".
console.log(webhook.id, webhook.signing_secret);

verify({ payload, headers, secret }) 会校验一次投递的签名并返回解析后的事件,否则会抛出异常。请传入原始请求体;headers 可以是 Headers 对象或普通对象。

webhook-handler.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
/** Works as a Bun, Deno, Cloudflare Workers or Next.js route handler. */
export async function POST(request: Request): Promise<Response> {
const payload = await request.text(); // the RAW body: do not parse it first
try {
const event = await spacepost.webhooks.verify({
payload,
headers: request.headers,
secret: process.env.SPACEPOST_WEBHOOK_SECRET!,
});
switch (event.type) {
case "email.bounced":
case "email.complained":
console.warn("Stop mailing", event.data.to);
break;
case "email.delivered":
console.log("Delivered", event.data.email_id);
break;
}
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
}

Webhook 事件带有类型。verify 返回 WebhookPayload,这是一个以 type 为判别字段的联合类型,因此根据 event.type 收窄类型后,你就能得到 data.bounce、data.click、data.open、data.failed 或 data.suppressed。当邮件是代表某个租户发送时,每个事件还会带有 data.tenant_id。参见事件类型。

平台模式:为你代发邮件的每个客户对应一个租户,拥有相互隔离的信誉、暂停开关和可选的每日上限。参见平台模式指南。

create-tenant.ts
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); // true

create({ name, externalId?, dailyLimit? })、get(idOrExternalId)、list({ limit, after, before })、update({ id, name?, externalId?, dailyLimit? })(dailyLimit: null 表示移除上限)、remove(idOrExternalId)(租户仍有域名时返回 422)、pause({ id, reason? })(或 pause(id))以及 unpause(idOrExternalId)(因信誉原因被暂停时返回 403,除非你的团队是 trusted)。凡是需要 id 的地方,都可以传入你的 external_id。

manage-tenants.ts
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 id
await 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");
contacts.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const { data: audience, error } = await spacepost.audiences.create({ name: "Newsletter" });
if (error) throw new Error(error.message);
// Create
await spacepost.contacts.create({
audienceId: audience.id,
email: "ada@example.com",
firstName: "Ada",
lastName: "Lovelace",
});
// Read by email (or by id)
const { data: contact } = await spacepost.contacts.get({ audienceId: audience.id, email: "ada@example.com" });
console.log(contact?.unsubscribed);
// Update
await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", firstName: "Augusta" });
// List, newest first
const { data: page } = await spacepost.contacts.list({ audienceId: audience.id, limit: 50 });
console.log(page?.data.length, page?.has_more);
// Unsubscribe manually, then delete
await spacepost.contacts.update({ audienceId: audience.id, email: "ada@example.com", unsubscribed: true });
await spacepost.contacts.remove({ audienceId: audience.id, email: "ada@example.com" });

联系人通过 audienceId 加上 id 或 email 来指定。

create、get、list、update(id, payload)(或 update({ id, ... }))、remove、send(id, { scheduledAt? })。参见群发。

broadcast.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
const audienceId = "0192e1c4-0000-7000-8000-0000000000a1";
// 1. Create a draft
const { data: draft, error } = await spacepost.broadcasts.create({
audienceId,
name: "October newsletter",
from: "Acme <news@acme.com>",
replyTo: "support@acme.com",
subject: "What's new at Acme, {{{FIRST_NAME|friend}}}",
previewText: "Three things we shipped this month",
html: `
<h1>Hi {{{FIRST_NAME|there}}},</h1>
<p>Here is what is new.</p>
<p><a href="{{{SPACEPOST_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>
`,
});
if (error) throw new Error(error.message);
// 2. Send it now, or schedule it
const { error: sendError } = await spacepost.broadcasts.send(draft.id, { scheduledAt: "in 1 hour" });
if (sendError) throw new Error(sendError.message);
// 3. Check progress
const { data: broadcast } = await spacepost.broadcasts.get(draft.id);
console.log(broadcast?.status); // "queued" | "sending" | "sent" | "failed"
suppressions.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost();
await spacepost.suppressions.create({ email: "do-not-mail@example.com" });
const { data } = await spacepost.suppressions.list({ limit: 100 });
for (const s of data?.data ?? []) console.log(s.email, s.reason);
await spacepost.suppressions.remove("do-not-mail@example.com"); // by email or id

emails.send 和 batch.send 的第二个参数接受 { idempotencyKey }。参见幂等键。

你并不一定需要 @spacepost/node:官方的 resend 包无需修改即可配合 SpacePost 使用。设置 RESEND_BASE_URL=https://api.spacepost.dev,或传入 new Resend(key, { baseUrl: "https://api.spacepost.dev" })。参见从 Resend 迁移。

tenant_id 之类的 SpacePost 扩展并不属于 Resend SDK 的方法,这些方法会丢弃未知字段。请使用 @spacepost/node、resend.post("/emails", { ..., tenant_id }) 或租户自有域名;参见平台模式。

所有请求和响应类型都已导出:import type { SendEmailOptions, Email, Domain, Tenant, WebhookPayload } from "@spacepost/node"。邮件和域名包含 tenant_id: string | null。