Skip to main content

Crate cratefield_adapter_owlpost

Crate cratefield_adapter_owlpost 

Source
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 on crates.io cratefield-adapter-owlpost documentation MIT

§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:

OperationEndpoint
Owlpost::send_with(message, SendOptions) — adds stream, topic, cc, bcc, scheduled_atPOST {base}/v1/emails
Owlpost::batch(emails, idempotency_key) — ≤ MAX_BATCH (100), the key is the batch’s Idempotency-Key, returns the ids in orderPOST {base}/v1/emails/batch
Owlpost::get_email(id) — returns an Email; id is validated to [A-Za-z0-9_-] firstGET {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.

OperationEndpoint
Owlpost::create_domain(name) — returns the domain with its recordsPOST {base}/v1/domains
Owlpost::list_domains() — entries may carry no recordsGET {base}/v1/domains
Owlpost::get_domain(id) — the records and the current statusGET {base}/v1/domains/{id}
Owlpost::verify_domain(id) — asks for a re-check; poll get_domain for the outcomePOST {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:

StatusVariant
(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
401MailError::Unauthorized
403 (domain/verify wording)MailError::DomainNotVerified { domain }
403 (otherwise)MailError::Unauthorized
400, 404, 422, other 4xxMailError::Invalid { detail }
429MailError::RateLimited { retry_after } from the Retry-After header
5xxMailError::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.

OperationEndpoint
Owlpost::list_messages(&MessageQuery) — inbox, thread, q, limit, before, held, all optionalGET {base}/v1/inbound/messages
Owlpost::list_held(inbox, before) — list_messages with status=heldGET {base}/v1/inbound/messages
Owlpost::get_message(id) — with the body, as a MessageDetailGET {base}/v1/inbound/messages/{id}
Owlpost::raw_message(id) — the RFC 822 source, as a StringGET {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 idPOST {base}/v1/inbound/messages/{id}/reply
Owlpost::release(id) — stops holding the messagePOST {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:

MethodRoute
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.
Email
One sent email, as GET {base}/v1/emails/{id} returns it.
MessageDetail
One message with its body — the only inbound type that carries one. #[non_exhaustive].
MessagePage
One page of messages, and the cursor for the page before them (None on the last page). #[non_exhaustive].
MessageQuery
The filters and cursor for Owlpost::list_messages: an inbox or thread id, a full-text q, a page limit, before — the previous page’s MessagePage::next — and held, 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 with MessageQuery::default and set fields.
MessageSummary
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 flattened MessageDetail cannot see through. #[non_exhaustive].
Owlpost
Mailer over POST {base}/v1/emails, holding the default sender and delegating the wire to OwlpostClient. Debug delegates to the client’s, so it never prints the key.
SendOptions
Extra send options Message does not carry. #[non_exhaustive]: build with SendOptions::default and set fields — not a struct literal.
Suppression
One suppression, as GET {base}/v1/emails/suppressions returns it. A topic of None is account-wide; reason is bounce, complaint or manual. #[non_exhaustive]: read the fields, do not build a literal.

Enums§

DomainError
What a sending-domain call can fail with. #[non_exhaustive]: match with a wildcard.
DomainStatus
Verification state of a Domain. #[non_exhaustive]: match with a wildcard.
OwlpostError
What one Owlpost call outside the Mailer port can fail with: the degraded-mode NotConfigured, or a wrapped MailError (a provider failure or a local refusal). #[non_exhaustive]: match with a wildcard.
RecordPurpose
What a record is for, from the record key.
RecordType
The DNS record type, from the type key.
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/emails is appended to whatever base is configured.
MAX_BATCH
The largest batch Owlpost::batch accepts (the Resend-compatible cap). A larger batch is refused without a request.