@crvouga/mockingbird-service-resend

中文文档
3.10.7发布于 2026/11/03MITTypeScriptES Module构建来源已验证近 30 天下载 1.6万

Stateful emulator of the Resend email API (send with idempotency, received emails, attachments) with an outbox and Svix-signed inbound webhooks (Mockingbird service contract).

原始使用文档
文档目录8 节
  1. Install
  2. Usage
  3. Routes
  4. Webhooks
  5. Admin (beyond the standard contract)
  6. Namespaces
  7. Deliberately not modelled
  8. API

@crvouga/mockingbird-service-resend

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator of the Resend email API for test suites. Every send lands in an outbox that tests read (GET /__admin/outbox, and the links in each email). Idempotency-Key replays return the first send's id. Inbound emails are stored and announced with a Svix-signed email.received webhook, and the received-email endpoints serve their content and attachments. With --forward-to-inbox, every sent email is also copied into the Mailosaur emulator, so one inbox holds every code and link.

  • Operation coverage: SUPPORT.md
  • openapi.yaml is trimmed from Resend's published API reference to what [email protected] and our backend call.

Install

npm install -D @crvouga/mockingbird-service-resend

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-resend serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

[email protected] reads RESEND_BASE_URL once, when the module is first imported, so set it in the app's environment (or before the first import("resend") in a test). Set the app's RESEND_INBOUND_WEBHOOK_SECRET and pass the same whsec_… value as --webhook-secret.

npx mockingbird-resend serve --port 8794 \
  --webhook-url http://127.0.0.1:3000/messaging/inbound/email \
  --webhook-secret "$RESEND_INBOUND_WEBHOOK_SECRET" \
  --forward-to-inbox http://127.0.0.1:8793
RESEND_BASE_URL=http://127.0.0.1:8794 node app.js
import { createServer } from "@crvouga/mockingbird-service-resend/server"

// Test setup: the emulator, and the base URL the Resend SDK reads at import.
const emulator = await createServer()
process.env.RESEND_BASE_URL = emulator.url
const { Resend } = await import("resend")

// The app's own Resend call.
await new Resend("re_test").emails.send({
  from: "Acme <[email protected]>",
  to: ["[email protected]"],
  subject: "You're invited",
  html: '<a href="https://app.test/family/invitations/claim?token=abc">Join</a>',
})

// The test reads the mail back from the outbox.
const { messages } = await (await fetch(`${emulator.url}/__admin/[email protected]`)).json()
const { links } = await (await fetch(`${emulator.url}/__admin/outbox/${messages[0].id}/links`)).json()
// links[0] === "https://app.test/family/invitations/claim?token=abc"
await emulator.close()

The outbox is the assertion surface for every send. An idempotencyKey replay returns the first send's id and leaves one message in it:

import { createServer } from "@crvouga/mockingbird-service-resend/server"

const emulator = await createServer()
process.env.RESEND_BASE_URL = emulator.url
const { Resend } = await import("resend")

const resend = new Resend("re_test")
const email = { from: "[email protected]", to: ["[email protected]"], subject: "Hi", text: "Hello" }
const first = await resend.emails.send(email, { idempotencyKey: "invite-1" })
const retry = await resend.emails.send(email, { idempotencyKey: "invite-1" })
// retry.data?.id === first.data?.id

const sent = await (await fetch(`${emulator.url}/__admin/[email protected]`)).json()
// sent.messages.length === 1
await emulator.close()

Routes

RouteBehaviour
POST /emails{from, to, subject, html?, text?, cc?, bcc?, reply_to?, headers?, tags?, attachments?, scheduled_at?} → {id} (a UUID). The SDK renders react to HTML before sending, so the emulator sees HTML. Violations answer Resend's body {statusCode, name, message}: 422 missing_required_field (Missing \to` field., or no html/text), 422 validation_error for a malformed address (Invalid `from` field. The email address needs to follow …), a tag outside [A-Za-z0-9_-], or any other contract violation. Idempotency-Key: the same key and payload replay the first 200 byte for byte (idempotent-replayed: true); another payload is 409 invalid_idempotent_request; a key still in flight is 409 concurrent_idempotent_requests; a key outside 1–256 characters is 400 invalid_idempotency_key`. Only 200s are remembered.
GET /emails/{id}emails.get: {object: "email", id, to, from, created_at, subject, html, text, cc, bcc, reply_to, last_event: "delivered" | "scheduled", scheduled_at, headers, tags}. headers keeps the names and values from the send; to keeps the original address form and order.
GET /emailsNewest-first sent email metadata, {object: "list", has_more, data}. limit defaults to 20 (1–100); after/before use email IDs and exclude the cursor. Content is available from the detail endpoint.
GET /emails/receivingNewest-first received email metadata with the same envelope and ID cursors. Without limit, returns every received email; an explicit limit must be 1–100. Includes attachment metadata and size.
GET /emails/receiving/{id}A received email: {object: "email", id, to, from, cc, bcc, reply_to, created_at, subject, html, text, headers, message_id, attachments[{id, filename, content_type, content_disposition, content_id}]}.
GET /emails/receiving/{id}/attachments{object: "list", has_more: false, data: [{id, filename, content_type, content_disposition, content_id, size, download_url, expires_at}]}.
GET /downloads/inbound/{attachment_id}The download_url: unauthenticated, the bytes with content-type and content-length. In a non-default namespace the URL carries /__admin/ns/<name>.

Auth is Authorization: Bearer <key>; any key works; none is 401 missing_api_key.

SDK error mapping. [email protected] never throws: a non-2xx JSON body comes back verbatim as {data: null, error: {statusCode, name, message}}; a non-JSON body becomes {name: "application_error", message: "Internal server error. …"}; a dropped connection becomes {name: "application_error", message: "Unable to fetch data. The request could not be resolved."}. [email protected] also reads RESEND_BASE_URL at import and returns that JSON error object, but its client does not catch a non-JSON body or a dropped socket (those reject). It also has no idempotencyKey option; send the Idempotency-Key header yourself, as [email protected] does.

Webhooks

POST /__admin/inbound stores the email, then posts to every endpoint:

{"type": "email.received", "created_at": "…",
 "data": {"email_id": "…", "created_at": "…", "from": "…", "to": ["[email protected]"], "cc": [], "bcc": [],
          "subject": "…", "message_id": "<…>", "attachments": [{"id", "filename", "content_type", "content_disposition", "content_id"}]}}

Like Resend's, the body carries no text, html, headers or download URLs, so a receiver hydrates them through the received-email routes. Pass "inline": true to include them. Each delivery is signed the Svix way: svix-id, svix-timestamp (wall clock, even when the emulator clock moves), svix-signature: v1,<base64 HMAC-SHA256(secret bytes, "id.ts.body")>. The official svix Webhook.verify accepts them. Non-2xx answers are retried (immediately, 5 s, 5 min, 30 min, 2 h). GET /__admin/webhooks, …/events, …/flush, …/:id/replay and PUT /__admin/webhook-endpoints (per-namespace receivers) come with the contract.

Admin (beyond the standard contract)

RouteEffect
GET /__admin/outcomes{accepted, lost, replayed} for sends whose response was dropped after acceptance, those lost responses, and idempotent replays.
GET /__admin/outbox?to=&tag=<name>:<value>&since=&limit=Sent emails, oldest first: {id, from, to (bare, lower-cased), toHeader, cc, bcc, replyTo, subject, html, text, tags, headers, attachments (filename, contentType, size), idempotencyKey, scheduledAt, createdAt}. tag=category matches any value. GET /__admin/outbox/:id returns one.
GET /__admin/outbox/:id/links{id, links}: every href in the HTML, entity-decoded (every URL in the text when there is no HTML).
POST /__admin/inbound{from, to, cc?, bcc?, replyTo?, subject?, text?, html?, headers?, messageId?, attachments?: [{filename, content (base64), contentType?, contentId?, contentDisposition?}], inline?} → 201 {id, webhook, event}.
GET /__admin/inboundReceived emails, oldest first.
GET /__admin/forwarding{target, forwarded, failed, lastError} for --forward-to-inbox.

--forward-to-inbox <url> copies every accepted send (not replays) to the Mailosaur emulator's POST /__admin/ingest, under the same namespace name, and waits up to 2 s for it. A failing inbox never fails the send; it is counted in /__admin/forwarding. Map the same key to the same namespace on both emulators and a worker reads its forwarded mail through the Mailosaur SDK.

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets): send_422 ({statusCode: 422, name: "validation_error", message}), send_429 (rate_limit_exceeded with retry-after and ratelimit-* headers), send_500 (internal_server_error), non_json_500 (an HTML 500 page), network_drop (the connection dies before acceptance, so nothing is stored), accepted_then_network_drop (the send is stored, then the connection dies before any response bytes; the same key replays), receiving_500 (the received-email routes answer 500), webhook_duplicate, webhook_reorder, webhook_drop.

Namespaces

new Resend(key) cannot add headers, so map API keys to namespaces: PUT /__admin/credentials {"credentials": {"<RESEND_API_KEY>": "<namespace>"}}. Also x-mockingbird-namespace, or a /__admin/ns/<name> prefix on RESEND_BASE_URL.

Deliberately not modelled

  • Delivery itself: no SMTP, bounces, complaints, opens or clicks, and no email.sent / email.delivered webhooks. last_event is delivered at once (scheduled with scheduled_at, which is stored but never fires).
  • Batch send, templates, domains, API keys, audiences, contacts and broadcasts (our code calls none).
  • Attachment path URLs are not fetched; sent attachments keep metadata only.
  • Rate limits, except through send_429.
  • The idempotency window: keys never expire (Resend keeps them 24 h).

API

ExportKindDescription
ResendAPIclassThe in-process emulator: fetch(request), reset(), sent(), sendOutcomes(), inbound(), receive(input, origin), state. Options: sqlite, now, namespace, publicNamespace, onSent, onOutcome.
createRuntimefunctionThe emulator with the full service contract (health, admin, namespaces, credentials, presets, Svix webhooks, outbox). Options: webhooks: {url, secret, retryDelaysMs?, fetch?}, forwardToInbox: {url, adminKey?, timeoutMs?, fetch?}, clock, seed, adminKey, onLog, sqlite.
forwardToInboxfunctionCopy one sent email into a Mailosaur emulator's ingest route.
RESEND_PRESETSobjectEvery named fault preset.
RESEND_NAMESPACEstringThe service name, "resend".
DOWNLOAD_URL_TTL_MSnumberThe advertised lifetime of a download_url (1 h).
bareAddressfunctionName <[email protected]> → [email protected] (how the outbox's ?to= compares).
document, operationIds, supportedOperationIdsvaluesThe vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server)NodeServe over node:http; the serve CLI target (--webhook-url, --webhook-secret, --forward-to-inbox); port 8794.

Part of mockingbird.

版本与发布记录

@crvouga/mockingbird-service-resend 共发布 39 个版本,首次发布于 2026/09/23;过去 12 个月发布了 39 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.10.7latest2026/11/038 天前
3.10.62026/11/038 天前
3.10.52026/11/038 天前
3.10.42026/11/038 天前
3.10.32026/11/038 天前
3.10.22026/11/038 天前

关于 @crvouga/mockingbird-service-resend 的常见问题

@crvouga/mockingbird-service-resend 是什么?

Stateful emulator of the Resend email API (send with idempotency, received emails, attachments) with an outbox and Svix-signed inbound webhooks (Mockingbird service contract).

如何安装 @crvouga/mockingbird-service-resend?

在项目目录运行 npm install @crvouga/mockingbird-service-resend,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-resend,可以用 npx mockingbird-resend 直接运行。

@crvouga/mockingbird-service-resend 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-resend 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-resend 可以商用吗?

@crvouga/mockingbird-service-resend 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-resend 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-resend 在 2026/10/11 – 2026/11/09 这 30 天内被下载 15,887 次,日均约 530 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-resend 还在维护吗?

最新版本 3.10.7 发布于 2026/11/03(8 天前);至今共发布 39 个版本,其中过去 12 个月发布了 39 个;首次发布于 2026/09/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。