@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
Maintainers
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.
sendViaBindingcallsenv.EMAIL.send()and answers with an outcome, never an exception; a classifier reads the documentederror.codeto tell a permanent failure (do not retry) from a transient one (retry, and for how long) from a suppressed recipient. ATransportinterface 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-Unsubscribeand one-clickList-Unsubscribe-Postheaders (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
Transportfor Resend's HTTP API: one message or a batch of up to a hundred, each with anIdempotency-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.
parseInboundoverpostal-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 apermanent_error(the message may have been sent; the error says so) and only a rate limit istransient. - The classifier reads the error
nameResend 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 waitsRetry-After, and at least two seconds) anddaily_quota_exceeded(it waits for the next UTC day).RESEND_ERROR_KINDSis 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.suppressedevent. tagson anOutgoingEmailbecome Resend tags, which come back in its events asDeliveryEvent.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, complainedverifyResendWebhook 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 elsepayload.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), automatedreplyAddress({ 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).
