@render-lab/tasks-resend
v0.1.2
Published
Durable Resend email tasks for Render Workflows: resend.send, resend.template, resend.awaitReply (Resend via fetch, console fallback).
Readme
@render-lab/tasks-resend
⚠️ Experimental: proof of concept. This package is part of the Render Tasks POC and is published for testing only. It is not fully tested or production ready. Task names, inputs, outputs, and behavior can change or break in any release. Pin exact versions and expect breaking changes.
Durable Resend email tasks for Render Workflows. Email is vendor-first (ADR-0016): this is the Resend pack, not a generic email capability. Other providers (SendGrid, Postmark) get their own packs.
import { send } from "@render-lab/tasks-resend";| Task | Input | Output | Transport |
| ------------------------ | ----------------------------------------------------------- | ------------------------------------- | --------------------------------- |
| resend.send | { to, from, subject, text?, html?, replyTo?, headers? } | { id, delivered } | Resend (console fallback) |
| resend.sendBatch | { messages: SendInput[] } | { ids } | Resend (console fallback) |
| resend.template | { subject, body, data? } | { subject, text, html } | pure (no I/O) |
| resend.awaitReply | { toAddress, correlationId, sinceISO? } | { replied, from, text, receivedAt } | injected inbox port |
| resend.createBroadcast | { audienceId, from, subject, html?, text?, name?, replyTo? } | { id } | Resend (key required) |
| resend.sendBroadcast | { id, scheduledAt? } | { id } | Resend (key required) |
| resend.addContact | { audienceId, email, firstName?, lastName?, unsubscribed? } | { id } | Resend (key required) |
| resend.listAudiences | {} | { id, name }[] | Resend (key required) |
| resend.getDeliveryStatus | { id } | { id, status, lastEvent? } | Resend (key required) |
| resend.verifyWebhook | { payload, headers, secret? } | { id, type, verified, data } | pure (Svix HMAC, no I/O) |
send posts to Resend (https://api.resend.com/emails) via the built-in fetch. If RESEND_API_KEY is unset, the task logs to the console and returns { id: null, delivered: false }, so a demo workflow runs end-to-end with zero email setup — only "real delivery vs. not" changes when you add the secret (mirrors the Slack webhook fallback). A non-ok Resend response throws, so the durable task retry handles transient failures.
sendBatch POSTs an array of messages to /emails/batch and mirrors send's console fallback (logs each message, returns { ids: [] }) when no key is set. The management/read tasks — createBroadcast, sendBroadcast, addContact, listAudiences, getDeliveryStatus — have no meaningful console equivalent (they must return real provider data), so they fail close with a clear RESEND_API_KEY required for … error when the key is unset rather than fall back. All map Resend responses to plain DTOs — raw provider payloads never cross the task boundary.
template renders {{var}} placeholders in the subject and body into a plain-text text and a minimal, HTML-escaped html (one <p> per non-empty line). It is pure — no network, no retry.
Verifying webhooks: resend.verifyWebhook
verifyWebhook authenticates a Resend webhook. Resend signs with Svix: the signed content is ${svix-id}.${svix-timestamp}.${rawBody}, HMAC-SHA256'd with the secret (the base64-decoded portion after the whsec_ prefix), base64-encoded; the svix-signature header is a space-separated list of v1,<base64> entries. The task recomputes the HMAC and constant-time-compares it against each v1, entry, accepting if any matches. It is pure (no port, no retry — a bad signature is a permanent failure) and reads its secret from the secret input or RESEND_WEBHOOK_SECRET. Pass the raw request body verbatim (do not re-serialize) or the signature will not match. On success it returns the JSON-parsed { id, type, verified: true, data }; it throws on a bad or missing signature so the caller can reject the request.
Durable human-in-the-loop: resend.awaitReply
awaitReply is a poll-with-retry task. SDK 0.6.0 has no native sleep/await, so the task throws when no matching reply is present yet, and the durable AWAIT_REPLY_RETRY policy (poll ~every 15s for up to ~1h) re-runs it until a human replies. The run parks on this task across restarts — the durable wait, made concrete.
POC limitation: there is no default inbox provider.
awaitReplyrequires an injecteddeps.inbox(anInboxPort) and throws a clear "no inbox configured" error until one is supplied. Wire your IMAP/webhook/provider inbox to theInboxPort.findReplyseam.
Install
pnpm add @render-lab/tasks-resend @renderinc/sdk@renderinc/sdk is a peer dependency. This package has no vendor SDK — it calls Resend with the built-in fetch.
Environment contract
| Variable | Required | Purpose |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
| RESEND_API_KEY | optional | Resend API key for resend.send/resend.sendBatch (console fallback if unset) and required for the broadcast/contact/audience/status tasks. Read lazily on first call. |
| RESEND_WEBHOOK_SECRET | optional | Svix signing secret (whsec_…) for resend.verifyWebhook. Only needed when you verify webhooks and don't pass secret in the input. Read lazily on first call. |
Extending & testing
send exports its raw sendImpl, which depends on an EmailPort. resendPort accepts an injected fetchImpl and env, so both the impl and the transport unit-test without network access:
import { resendPort } from "@render-lab/tasks-resend";
const port = resendPort({ env: {} }); // no key -> console fallback
await port.send({ to: "[email protected]", from: "[email protected]", subject: "hi" }); // { id: null, delivered: false }Run the tests with pnpm -C packages/tasks-resend test.
