Expand description
cratefield-adapter-owlpost: the Mailer port over the Owlpost REST
API (issue #591), the harness’s second mail provider alongside
adapter-resend. Uses the runtime’s HttpClient port — no reqwest,
no vendor SDK — so the same adapter runs on Workers and natively.
Owlpost is Resend-compatible: the same POST {base}/v1/emails request
shape in, the same {"id": "..."} body out. The differences are the
endpoint host, the OWLPOST_* secrets and the error body — Owlpost
answers RFC 9457 application/problem+json (type, title, status,
detail) where Resend answers {"message": "..."}.
Degraded mode. With no OWLPOST_API_KEY (or a blank one) the adapter
reports SendOutcome::NotConfigured without any network call, so forms
keep working — the same mode adapter-resend ships.
§cratefield-adapter-owlpost
Mailer port over the Owlpost REST API for the
Cratefield harness (issue #591). Uses the runtime’s HttpClient port — no
reqwest, no vendor SDK — so it runs unchanged on Workers (worker::Fetch)
and natively. It is the second mail provider alongside adapter-resend, and
speaks the same Resend-compatible POST {base}/v1/emails wire.
§Usage
use std::sync::Arc;
use cratefield_adapter_owlpost::Owlpost;
use cratefield_runtime_cloudflare::{FetchClient, WorkersClock};
// With a key:
let mailer = Owlpost::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);Owlpost::from_env(http, clock) reads OWLPOST_API_KEY, OWLPOST_BASE_URL
(default DEFAULT_BASE_URL, https://api.owlpost.to), MAIL_FROM and
MAIL_REPLY_TO. A blank key or base URL reads as unset. On Workers, read the
secrets from the venture’s Env and call Owlpost::new. A self-hosted
deployment or proxy points the adapter at its own host with
Owlpost::with_base_url or OWLPOST_BASE_URL. 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”.
§Operations
Beyond the Mailer port (one message at POST {base}/v1/emails), each
call delegates to OwlpostClient, which owns the wire:
| Operation | Endpoint |
|---|---|
Owlpost::send_with(message, SendOptions) — adds stream, topic, cc, bcc, scheduled_at | POST {base}/v1/emails |
Owlpost::batch(emails, idempotency_key) — ≤ MAX_BATCH (100), the key is the batch’s Idempotency-Key, returns the ids in order | POST {base}/v1/emails/batch |
Owlpost::get_email(id) — returns an Email; id is validated to [A-Za-z0-9_-] first | GET {base}/v1/emails/{id} |
SendOptions and Stream are #[non_exhaustive]: build with
SendOptions::default() and set fields, not a struct literal. Unset fields
are omitted, so a plain send body is unchanged. A topic with
Stream::Transactional, an empty or oversized batch, and an empty or
path-like id are refused locally, with no request — the refusals are checked
before the key, so a programming error surfaces even in keyless dev.
§Webhooks
webhook verifies and parses Owlpost’s inbound events. Owlpost signs
Stripe-style — Cratefield-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "{t}.{body}"> — at core’s ±300 s tolerance, widened with
verifier().tolerance_secs(n). parse_verified verifies the raw body
bytes first and returns WebhookError::Signature without parsing them
when that fails, so an unverified delivery is never read.
The 13 event types (email.sent/delivered/delivery_delayed/bounced/
soft_bounced/complained/unsubscribed/rejected/opened/clicked/
failed, message.received/held) each parse into a typed OwlpostEvent
variant. A type this crate does not know becomes
OwlpostEvent::Unknown(type_string) — carried, not an error, so a handler
can ignore it.
Deduplicate on Envelope::id, not on the header. The signature covers
t and the body only, so Cratefield-Event-Id is unauthenticated: a
captured delivery replayed with a fresh id header still verifies and would
slip past a header-keyed ledger. event_id(headers) reads that header for
correlation and logging — reading it before verification, for instance —
and nothing more.
use cratefield_adapter_owlpost::webhook::{OwlpostEvent, event_id, parse_verified, verifier};
let envelope = parse_verified(&verifier(), secret, &headers, body, now_unix)?;
// `envelope.id` is inside the signed body — this is the dedup key.
let seen = inbox.claim(&envelope.id)?;
let _correlation = event_id(&headers); // logging only
match &envelope.data {
OwlpostEvent::EmailBounced(b) => log(&format!("bounce {}", b.code.clone().unwrap_or_default())),
OwlpostEvent::Unknown(t) => log(&format!("unhandled event {t}")),
_ => {}
}§Sending domains
The /v1/domains routes, so a venture can register a sending domain, show
the DNS records to publish, and watch verification. The API key needs the
domains:manage scope.
| Operation | Endpoint |
|---|---|
Owlpost::create_domain(name) — returns the domain with its records | POST {base}/v1/domains |
Owlpost::list_domains() — entries may carry no records | GET {base}/v1/domains |
Owlpost::get_domain(id) — the records and the current status | GET {base}/v1/domains/{id} |
Owlpost::verify_domain(id) — asks for a re-check; poll get_domain for the outcome | POST {base}/v1/domains/{id}/verify |
Owlpost::delete_domain(id) | DELETE {base}/v1/domains/{id} |
Domain carries Vec<DnsRecord>, each typed as purpose (DKIM, MAIL FROM, SPF), kind (TXT, MX, CNAME), name, value, ttl,
priority and status — everything a DNS-setting UI needs without parsing
strings; both it and its records serialize, so a venture can hand them
straight to its frontend. These routes return DomainError, not
OwlpostError: 404 → NotFound, 409 → AlreadyExists, 400/422 → Invalid
(the same as a local refusal), and 403 → Mail(Unauthorized) — on a domain
route a 403 is a key without the domains:manage scope, never an unverified
sending domain. Every other status maps as in the table below, wrapped in
DomainError::Mail. A provider state or record purpose added later parses as
Unknown rather than failing.
§Error mapping
Owlpost answers RFC 9457 application/problem+json. OwlpostError is
either NotConfigured or a wrapped cratefield_core::MailError, so the
Mailer port and the extra operations share one mapping:
| Status | Variant |
|---|---|
| (no API key) | OwlpostError::NotConfigured; Mailer::send reports Ok(SendOutcome::NotConfigured) instead |
| (local refusal) | MailError::Invalid { detail } — empty/oversized batch, topic on a transactional message, bad id |
| 401 | MailError::Unauthorized |
| 403 (domain/verify wording) | MailError::DomainNotVerified { domain } |
| 403 (otherwise) | MailError::Unauthorized |
| 400, 404, 422, other 4xx | MailError::Invalid { detail } |
| 429 | MailError::RateLimited { retry_after } from the Retry-After header |
| 5xx | MailError::Upstream(detail), with the Retry-After hint appended when present |
No error Display or Debug ever includes the API key: provider and
transport text is key-redacted when the error is built, and Display
additionally runs it through cratefield_core::scrub_text. text is always
sent alongside html; the Idempotency-Key header is set from
Message::idempotency_key (for a batch, from the batch’s own key).
§Keys
op_test_… keys never reach a provider; op_live_… keys do. Use a test key
in development and the venture’s live key in production, set with
wrangler secret put OWLPOST_API_KEY.
§The request fixture
tests/fixtures/owlpost-send-email-request.schema.json is the request schema
the contract test validates every recorded body against. Its canonical source
is Owlpost-to/backend:crates/owlpost-core, a private repository unreachable
from this one, so the fixture is authored from the Resend-compatible shape
Owlpost speaks and carries the provenance in a $comment. Refresh it from a
local checkout with scripts/refresh-fixture.sh.
§Inbound
Owlpost also receives: the /v1/inbound/messages API (issue #682). Reads need
the inbound:read scope, writes inbound:manage.
| Operation | Endpoint |
|---|---|
Owlpost::list_messages(&MessageQuery) — inbox, thread, q, limit, before, held, all optional | GET {base}/v1/inbound/messages |
Owlpost::list_held(inbox, before) — list_messages with status=held | GET {base}/v1/inbound/messages |
Owlpost::get_message(id) — with the body, as a MessageDetail | GET {base}/v1/inbound/messages/{id} |
Owlpost::raw_message(id) — the RFC 822 source, as a String | GET {base}/v1/inbound/messages/{id}/raw |
Owlpost::reply(id, message) — sends to, subject, text, html (Owlpost takes the sender from the inbox), returns the reply’s id | POST {base}/v1/inbound/messages/{id}/reply |
Owlpost::release(id) — stops holding the message | POST {base}/v1/inbound/messages/{id}/release |
A held message’s body is never part of a listing: the page item MessageSummary
has no body field, so a held body is readable only through get_message or
raw_message, and raw_message returns the source verbatim. MessageQuery is
#[non_exhaustive] — build it with
MessageQuery::default() and set fields. Listings are cursor-paged:
MessagePage::next is the cursor to pass back as MessageQuery::before, and is
None on the last page. Message ids are validated to [A-Za-z0-9_-] before any
request, as outbound ids are. Inboxes, threads and routes follow.
§Suppressions and topics
Per the published Owlpost docs:
| Method | Route |
|---|---|
list_suppressions(topic?) | GET /v1/emails/suppressions (?topic=, percent-encoded) |
add_suppression(address, topic?) | POST /v1/emails/suppressions, body {address, topic?} |
remove_suppression(address, topic?, reason?) | DELETE /v1/emails/suppressions/{address}, topic/reason as query parameters |
set_topic_name(topic, name) | PUT /v1/emails/topics/{topic}, body {name} |
An absent topic is an account-wide entry. Every route that takes a topic
checks it against Owlpost’s charset (a-z 0-9 : _ -, 1–64 chars), then
percent-encodes it; set_topic_name also checks the name’s length (1–200).
Empty address, topic and reason fields are refused locally too. A 404 or 422
is MailError::Invalid { detail } like every other route.
MIT. Built in the open for Cratefield, a Factory Zero venture.
Re-exports§
pub use webhook::Envelope;pub use webhook::OwlpostEvent;pub use webhook::WebhookError;pub use webhook::parse_verified;
Modules§
- webhook
- Owlpost’s inbound webhooks: verify the delivery, then parse it (issue
#668). The inbound sibling of
Owlpost, which sends.
Structs§
- DnsRecord
- One DNS record a venture must publish for a
Domain. - Domain
- A sending domain, with the DNS records a venture must publish.
- One sent email, as
GET {base}/v1/emails/{id}returns it. - Message
Detail - One message with its body — the only inbound type that carries one.
#[non_exhaustive]. - Message
Page - One page of messages, and the cursor for the page before them (
Noneon the last page).#[non_exhaustive]. - Message
Query - The filters and cursor for
Owlpost::list_messages: an inbox or thread id, a full-textq, a pagelimit,before— the previous page’sMessagePage::next— andheld, the only lifecycle state (status=held) a listing can be filtered to. Every field is optional, and an empty one is left off the query string.#[non_exhaustive]: build withMessageQuery::defaultand set fields. - Message
Summary - What a listing says about a message — and all it may say: there is no body
field, so a held message’s HTML and text cannot leak into a list; fetch
them with
Owlpost::get_message. Fields are defaulted one by one, not by a container#[serde(default)], which a flattenedMessageDetailcannot see through.#[non_exhaustive]. - Owlpost
MaileroverPOST {base}/v1/emails, holding the default sender and delegating the wire toOwlpostClient.Debugdelegates to the client’s, so it never prints the key.- Send
Options - Extra send options
Messagedoes not carry.#[non_exhaustive]: build withSendOptions::defaultand set fields — not a struct literal. - Suppression
- One suppression, as
GET {base}/v1/emails/suppressionsreturns it. AtopicofNoneis account-wide;reasonisbounce,complaintormanual.#[non_exhaustive]: read the fields, do not build a literal.
Enums§
- Domain
Error - What a sending-domain call can fail with.
#[non_exhaustive]: match with a wildcard. - Domain
Status - Verification state of a
Domain.#[non_exhaustive]: match with a wildcard. - Owlpost
Error - What one Owlpost call outside the
Mailerport can fail with: the degraded-modeNotConfigured, or a wrappedMailError(a provider failure or a local refusal).#[non_exhaustive]: match with a wildcard. - Record
Purpose - What a record is for, from the
recordkey. - Record
Type - The DNS record type, from the
typekey. - Stream
- The stream a message is sent on, serialised lowercase.
Constants§
- DEFAULT_
BASE_ URL - The hosted Owlpost API. A self-hosted or proxy deployment points the
adapter elsewhere with
Owlpost::with_base_url/OWLPOST_BASE_URL; the wire path/v1/emailsis appended to whatever base is configured. - MAX_
BATCH - The largest batch
Owlpost::batchaccepts (the Resend-compatible cap). A larger batch is refused without a request.