Expand description
cratefield-adapter-resend: the Mailer port over the Resend REST API
(issue #6). Uses the runtime’s HttpClient port — no reqwest, no
vendor SDK — so the same adapter runs on Workers and natively.
Degraded mode. The Resend account has no verified domain yet; until
send.<domain> is verified, real sends 403. When the API key is absent
the adapter reports SendOutcome::NotConfigured without any network
call, so forms keep working.
§cratefield-adapter-resend
Mailer port over the Resend REST API for the
Cratefield harness. Uses the runtime’s HttpClient port — no reqwest,
no vendor SDK — so it runs unchanged on Workers (worker::Fetch) and
natively.
It also carries the inbound direction: ResendInbound verifies Resend’s
email.received webhook with the Svix scheme and parses it into an
InboundMessage, ready to hand to a module. The webhook is metadata only
— the message body is a separate Received-emails API call the caller
makes, not this crate. Configure the signing secret with
ResendInbound::new(secret) or RESEND_WEBHOOK_SECRET via
ResendInbound::from_env().
§Usage
use std::sync::Arc;
use cratefield_adapter_resend::Resend;
use cratefield_runtime_cloudflare::{FetchClient, WorkersClock};
// With a key:
let mailer = Resend::new(
Arc::new(FetchClient),
Arc::new(WorkersClock),
Some(key),
"Acme <no-reply@send.example.com>",
None,
);
// Without a key (degraded mode): send() -> Ok(SendOutcome::NotConfigured),
// no network call. Wire it into the runtime:
let runtime = Cloudflare::new().db("DB").mailer(mailer);Resend::from_env(http, clock) reads RESEND_API_KEY, MAIL_FROM,
MAIL_REPLY_TO from the process environment (native/self-hosted). On
Workers, read the secrets from the venture’s Env and call Resend::new.
The Clock is what lets the adapter read the HTTP-date form of
Retry-After (issue #278); without it a date-form 429 would read as
“retry now”.
Error mapping (to cratefield_core::MailError): 401/403 →
Unauthorized/DomainNotVerified { domain } (domain parsed from Resend’s
message), 422 → Invalid { detail }, 429 → RateLimited { retry_after }
(from the Retry-After header), 5xx → Upstream. No error Display ever
includes the API key. text is always sent alongside html; the
Idempotency-Key header is set from Message::idempotency_key.
§Verify a sending subdomain (not the apex)
The Resend account has no verified domain yet; until it does, real
sends 403 with “Domain … is not verified” and this adapter maps that to
MailError::DomainNotVerified. Until then, forms run in the degraded
NotConfigured/503 mode.
When verifying:
- In the Resend dashboard add a subdomain, e.g.
send.example.com, not the apex. - Add the DKIM/SPF records Resend shows to the subdomain’s DNS zone.
- Wait for “Verified”, then set
MAIL_FROMto an address on that subdomain (e.g.Acme <no-reply@send.example.com>).
Why not the apex? The apex domain (e.g. example.com) carries the
inbound Email Routing MX records. Adding Resend’s outbound DKIM/SPF to the
apex would mix inbound routing and outbound sending policy on the same
name and can break delivered mail and future provider moves. A dedicated
send. subdomain keeps them isolated, and lets inbound routing stay
untouched if the outbound provider ever changes.
§Probing the key
Until a domain is verified, the key is send-only. Probe it with a real
send (expect 403 DomainNotVerified, which proves the key itself is
valid):
curl -s -X POST https://api.resend.com/emails \
-H "authorization: Bearer $RESEND_API_KEY" \
-H "content-type: application/json" \
-d '{"from":"probe@send.example.com","to":"you@example.com","subject":"probe","text":"probe"}'A 401 means the key is bad; 403 with the domain message means the key works but the domain is unverified.
MIT. Built in the open for Cratefield, a Factory Zero venture.
Structs§
- Resend
MaileroverPOST https://api.resend.com/emails.- Resend
Inbound - Resend’s
email.receivedwebhook, over core’sSvixscheme.