npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vibedout/email

v0.2.0

Published

Email for Cloudflare Workers: a send adapter for the Email Service binding with an error classifier on the documented error codes, a Transport interface (with a batch form), a Resend transport (single and batch sends with idempotency keys, an error classi

Readme

@vibedout/email

Email for Cloudflare Workers, built around the Cloudflare Email Service binding (send_email), with Resend as a second transport for the mail the Email Service is not meant for.

  • Sending. sendViaBinding calls env.EMAIL.send() and answers with an outcome, never an exception; a classifier reads the documented error.code to tell a permanent failure (do not retry) from a transient one (retry, and for how long) from a suppressed recipient. A Transport interface sits in front of it, with the Cloudflare implementation and a recording fake.
  • Safe rendering. A {{var}} engine for subjects, text and HTML fragments (escaping on by default, no raw interpolation, no line break reaches a header), a themed table layout with a localized footer, header and address safety (line breaks, display-name quoting, internationalized domains).
  • Unsubscribe. A signed, never-expiring token, the List-Unsubscribe and one-click List-Unsubscribe-Post headers (RFC 8058), and the confirmation page in es-MX and en.
  • Templates people edit. A whitelisted JSON document (headings, paragraphs, lists, a button, an image, a divider) with a zod schema and a server renderer to HTML and plain text through the same layout. No HTML is stored or rendered.
  • React Email. renderReactEmail(element) for templates written in code.
  • Resend. A Transport for Resend's HTTP API: one message or a batch of up to a hundred, each with an Idempotency-Key, an error classifier on the names Resend documents, and its Svix-signed delivery webhooks checked and turned into the same delivery events the Email Service produces.
  • Delivery events. The Email Service's queue events (delivered, deferred, bounced, failed, rejected, complained) normalized into one shape, with the status each implies and the suppression it asks for.
  • Received mail. parseInbound over postal-mime, and a signed reply-address codec (reply+<id>.<mac>@in.example.com) that ties a reply to the message it answers.
  • The provider's suppression list. A scope- and expiry-aware reader of Cloudflare's suppression API.

Web platform APIs and WebCrypto only; no required runtime dependency. It builds on three shared packages (@vibedout/web-crypto, @vibedout/provider-kit, @vibedout/capability) that its build bundles, so the published package imports none of them at run time. Four optional peers serve one subpath each: zod (/document), postal-mime (/inbound), and @react-email/render with react (/react). The root import and every other subpath need none of them.

Two things follow from bundling: the bundled EmailError is this package's own copy of the provider error class, so error instanceof ProviderError with a class from another package is false for it (use error instanceof EmailError or error.name); and the package ships no Worker, no queue consumer and no database: those are the application's, and this is what they are made of.

Module map

| Import | What | |---|---| | @vibedout/email | Everything that needs no optional peer: the modules below except document, inbound, react and testing | | @vibedout/email/address | isPlausibleEmail, normalizeEmail, hashEmail, maskEmail, isTestRecipient, toAsciiAddress, formatAddress, splitAddress, hasHeaderInjection, assertNoHeaderInjection, HeaderInjectionError | | @vibedout/email/html | escapeHtml, unescapeHtml | | @vibedout/email/render | renderHtml, renderText, renderSubject, htmlToText, stripLineBreaks, TemplateVars | | @vibedout/email/layout | wrapInLayout, LayoutTheme, DEFAULT_THEME, EmailLocale | | @vibedout/email/unsubscribe | signUnsubscribeToken, verifyUnsubscribeToken, unsubscribeKeyring, buildUnsubscribeUrl, listUnsubscribeHeaders, unsubscribePage, pickUnsubscribeLocale | | @vibedout/email/cloudflare | sendViaBinding, classifySendError, SEND_ERROR_KINDS, createCloudflareTransport, attachment helpers, listCloudflareSuppressions and the entry helpers | | @vibedout/email/transport | Transport, BatchTransport, isBatchTransport, MessageCategory, assertTransportSupports | | @vibedout/email/resend | createResendTransport, RESEND_ERROR_KINDS, verifyResendWebhook, parseResendEvent, RESEND_EVENT_TYPES | | @vibedout/email/events | parseSendingEvent, eventStatus, eventSuppression, shouldAdvanceStatus, eventMessageIdCandidates | | @vibedout/email/reply-address | replyAddress, verifyReplyAddress, extractReplyIds | | @vibedout/email/errors | EmailError, EmailErrorCode | | @vibedout/email/document | parseEmailDocument, renderDocument, renderDocumentEmail, EmailDocumentSchema (needs zod) | | @vibedout/email/inbound | parseInbound, readRawMessage, replyTargetIds (needs postal-mime) | | @vibedout/email/react | renderReactEmail (needs @react-email/render and react) | | @vibedout/email/testing | createFakeSendBinding, createFakeTransport, createFakeResend, signResendWebhook, resendEvent, sendingEvent, buildRawEmail, bindingError |

Nothing under src/ is importable: only the paths in this table exist.

Sending

import { createCloudflareTransport, sendViaBinding } from "@vibedout/email";

const outcome = await sendViaBinding(env.EMAIL, {
  from: { name: "Estudio Luna", email: "[email protected]" },
  to: "[email protected]",
  subject: "Tu cita está confirmada",
  html,
  text,
  listUnsubscribeUrl, // optional: adds List-Unsubscribe and the one-click post header
});

switch (outcome.status) {
  case "sent":            // outcome.providerMessageId
  case "suppressed":      // the provider has this recipient on its list: final
  case "permanent_error": // a bad message or a configuration fault: final, never retry
  case "transient_error": // retry; outcome.retryAfterSeconds is a floor for the wait when the error implies one
}

sendViaBinding checks the message before it calls the provider and answers permanent_error (code local_invalid_message) for a malformed recipient, sender or reply-to, more than 50 recipients, a line break in a subject, sender name or header, and a listUnsubscribeUrl that is not https. The binding type is structural (SendEmailBinding), so the Workers SendEmail satisfies it and no ambient types are needed.

The classifier

classifySendError(error) reads error.code and nothing else: the message is prose, the code is the contract. Every code the binding documents has an entry in SEND_ERROR_KINDS, and a test pins the table against the documented list.

| Kind | Codes | |---|---| | suppressed | E_RECIPIENT_SUPPRESSED | | permanent | E_VALIDATION_ERROR, E_FIELD_MISSING, E_TOO_MANY_RECIPIENTS, E_TOO_MANY_ATTACHMENTS, E_SENDER_NOT_VERIFIED, E_RECIPIENT_NOT_ALLOWED, E_SENDER_DOMAIN_NOT_AVAILABLE, E_CONTENT_TOO_LARGE, E_HEADER_NOT_ALLOWED, E_HEADER_USE_API_FIELD, E_HEADER_VALUE_INVALID, E_HEADER_VALUE_TOO_LONG, E_HEADER_NAME_INVALID, E_HEADERS_TOO_LARGE, E_HEADERS_TOO_MANY | | transient | E_DELIVERY_FAILED, E_RATE_LIMIT_EXCEEDED (wait a minute), E_DAILY_LIMIT_EXCEEDED (wait until the next UTC day), E_INTERNAL_SERVER_ERROR |

An error with no code (a network failure) or a code this table does not know is transient with known: false: the safe guess, bounded by the queue's own retry limit. E_DELIVERY_FAILED is transient because a hard bounce makes the provider suppress the recipient, so the next attempt ends as E_RECIPIENT_SUPPRESSED instead of repeating. E_SENDER_NOT_VERIFIED and its kin are permanent for the message, but they are faults an operator must hear about: log them loudly.

Transports

interface Transport {
  readonly name: string;
  supports(category: "transactional" | "lifecycle" | "marketing"): boolean;
  send(email: OutgoingEmail, options?: SendOptions): Promise<SendOutcome>;
}

createCloudflareTransport(env.EMAIL) carries transactional and lifecycle mail and refuses marketing: the Email Service is intended for transactional mail until Cloudflare ships marketing support. assertTransportSupports(transport, category) throws before a message is queued; a project that needs newsletters adds a second transport for that category only. createFakeTransport() (from /testing) records what it is asked to send and answers as scripted.

Resend

import { createResendTransport, isBatchTransport } from "@vibedout/email";

const resend = createResendTransport({ apiKey: env.RESEND_API_KEY, categories: ["marketing"] });
const outcome = await resend.send(email, { idempotencyKey: `msg:${messageId}` });
// a hundred at a time, one outcome per message in the order given
const outcomes = await resend.sendBatch(emails, { idempotencyKey: `batch:${batchId}` });

The answers are the same SendOutcomes as the Cloudflare transport's, so one queue consumer handles both. What differs:

  • Idempotency. Pass a key derived from your own record. Resend keeps it for 24 hours: the same key and message answers the same id, and the same key with another message is invalid_idempotent_request (permanent). The key is optional on the transport; a repeat without one can send twice, so a call with no key that timed out, could not connect or was answered with a 5xx is a permanent_error (the message may have been sent; the error says so) and only a rate limit is transient.
  • The classifier reads the error name Resend documents, and the status only for a name it does not know. Permanent: validation_error (a field, or a sender domain that is not verified), the key errors (missing_api_key, restricted_api_key, suspended_api_key, invalid_permission), monthly_quota_exceeded. Transient: application_error, service_unavailable, concurrent_idempotent_requests, resource_locked, rate_limit_exceeded (it waits Retry-After, and at least two seconds) and daily_quota_exceeded (it waits for the next UTC day). RESEND_ERROR_KINDS is pinned by a test to the documented list.
  • A batch is all or nothing at Resend. A message refused locally (a malformed address) is left out of the call and has its own permanent_error; if the call fails, every message in it has that outcome. It throws for more than a hundred messages and for an attachment, which Resend's batch call does not take.
  • No "suppressed" outcome. Resend accepts a message and decides later; a suppressed recipient is an email.suppressed event.
  • tags on an OutgoingEmail become Resend tags, which come back in its events as DeliveryEvent.tags (an object of strings): how a project tells its own mail from that of other apps on the same account.
import { parseResendEvent, verifyResendWebhook } from "@vibedout/email";

const { deliveryId, payload } = await verifyResendWebhook({ headers: request.headers, rawBody: await request.text(), secret: env.RESEND_WEBHOOK_SECRET });
const parsed = parseResendEvent(deliveryId, payload);
if (parsed.ok) await applyDeliveryEvent(parsed.event); // delivered, deferred, bounced, failed, suppressed, complained

verifyResendWebhook throws webhook_signature_invalid (answer 4xx; a retry cannot help), webhook_expired (genuine but outside the five-minute window) or configuration_invalid (no usable whsec_ secret). The signature is checked before the clock, over the body exactly as received. parseResendEvent returns { ok: false, reason: "unknown_type" } for the events that are not delivery events (email.sent, email.opened, ...): acknowledge them. createFakeResend() from /testing implements the documented API for tests, and signResendWebhook makes a signed delivery.

Rendering

renderHtml(source, vars) escapes every {{var}}; renderSubject and renderText do not escape but replace each line break in a substituted value with a space. {{#if var}}...{{/if}} includes a block when the variable is truthy. There is no {{{raw}}}, a dotted name is one literal key, and a missing variable is the empty string.

wrapInLayout({ contentHtml, senderName, unsubscribeUrl, locale, theme, via, postalAddress, preheader }) wraps already-escaped content in the table layout with a dark-mode block and a footer in es-MX or en. A brand is a LayoutTheme (DEFAULT_THEME is neutral).

toAsciiAddress("ana@münchen.de") gives [email protected], or null for an address that cannot be sent as it is (a non-ASCII local part, a domain that is not a hostname). formatAddress({ email, name }) returns Name <email> with the name bare, quoted or RFC 2047-encoded as its characters need, and throws on a line break.

Unsubscribe

const token = await signUnsubscribeToken(env.UNSUBSCRIBE_SECRET, { contactId, topic: "reminders" });
const url = buildUnsubscribeUrl("https://app.example.com/unsubscribe", token, "en");
// send with listUnsubscribeUrl: url, then in the route:
//   GET  -> unsubscribePage("confirm", locale, { action })   (a link scanner that fetches it unsubscribes nobody)
//   POST -> verifyUnsubscribeToken(secret, t), add the suppression, unsubscribePage("done", locale)

The token is a durable signed value that never expires, so a link in an old message keeps working; rotate by passing a keyring with a second key. listUnsubscribeHeaders({ url, mailto }) builds the two headers (the provider rejects http, and a post header without an https address). pickUnsubscribeLocale({ param, acceptLanguage }) chooses the page language from the link's l parameter, then Accept-Language, then es-MX.

Templates people edit

import { parseEmailDocument, renderDocumentEmail } from "@vibedout/email/document";

const checked = parseEmailDocument(body, { allowedImageHosts: ["img.example.com"] }); // { ok, document } | { ok: false, issues }
const email = renderDocumentEmail(storedDocument, { subject: "Hola {{name}}", senderName, unsubscribeUrl: null, locale: "es-MX", vars });
// { subject, html, text, warnings }

A document is { "version": 1, "type": "doc", "content": [...] } of heading (levels 1-3), paragraph, bulletList, button, image, divider and spacer blocks, whose text carries bold, italic and link marks and variable nodes. Every object is strict (an unknown key is refused), a link is https:, mailto: or a single {{variable}} that is checked again when it is filled in, an image is https:, and the size is bounded. The renderer validates every time, escapes everything, and drops (and reports in warnings) a link whose variable is not a safe address.

Delivery events

Create an event subscription for the sending domain with a Queue as its destination and read it with a queue consumer:

import { eventStatus, eventSuppression, parseSendingEvent, shouldAdvanceStatus } from "@vibedout/email";

const parsed = parseSendingEvent(message.body);
if (!parsed.ok) return message.ack(); // an event type this version does not know
const { event } = parsed;             // event.eventId is the dedupe key; delivery is at least once and unordered
if (shouldAdvanceStatus(current, eventStatus(event))) { /* update the message */ }
const suppression = eventSuppression(event); // a complaint: for good; a hard bounce: a week; nothing else

payload.messageId of an event is the provider's id for the message. The documentation does not say whether it equals the messageId that send() returned, so match on eventMessageIdCandidates(event) first and fall back to the recipient and a time window.

Received mail

Cloudflare keeps no inbox and has no API to read one: the Worker's email() handler is the only chance to keep a message. Read the raw stream once, store the bytes, parse them:

import { parseInbound, readRawMessage } from "@vibedout/email/inbound";

const bytes = await readRawMessage(message.raw); // refuses more than 25 MiB
await env.INBOUND.put(key, bytes);               // the raw .eml
const mail = await parseInbound(bytes);          // from, to, subject, text, html, messageId, inReplyTo, references, attachments (meta only), automated

replyAddress({ secret, domain }, id) makes reply+<id>.<mac>@in.example.com, to set as the Reply-To of a message; extractReplyIds(options, mail.to.map((a) => a.address)) gives the ids whose signature checks out. The provider owns the Message-ID header, so In-Reply-To can only be matched against ids you stored (replyTargetIds(mail)); the signed address does not depend on it.

React Email

import { renderReactEmail } from "@vibedout/email/react";
const { html, text } = await renderReactEmail(<BookingConfirmed name="Ana" />);

Render in the queue consumer, not on a request path.

Errors

EmailError is built on the shared provider error family: code (the shared vocabulary plus header_injection and invalid_address), retryable, issues for validation_failed, status and providerCode when a response caused it. Sending is the exception to "throws": a send answers with an outcome.

Tests

/testing has createFakeSendBinding (records calls, throws errors shaped like the binding's), createFakeTransport, sendingEvent(type) (a queue body as the Email Service publishes it) and buildRawEmail (a small RFC 5322 message).