Skip to main content

cratefield_adapter_owlpost/
lib.rs

1//! `cratefield-adapter-owlpost`: the [`Mailer`] port over the Owlpost REST
2//! API (issue #591), the harness's second mail provider alongside
3//! `adapter-resend`. Uses the runtime's [`HttpClient`] port — no `reqwest`,
4//! no vendor SDK — so the same adapter runs on Workers and natively.
5//!
6//! Owlpost is Resend-compatible: the same `POST {base}/v1/emails` request
7//! shape in, the same `{"id": "..."}` body out. The differences are the
8//! endpoint host, the `OWLPOST_*` secrets and the error body — Owlpost
9//! answers RFC 9457 `application/problem+json` (`type`, `title`, `status`,
10//! `detail`) where Resend answers `{"message": "..."}`.
11//!
12//! **Degraded mode.** With no `OWLPOST_API_KEY` (or a blank one) the adapter
13//! reports [`SendOutcome::NotConfigured`] without any network call, so forms
14//! keep working — the same mode `adapter-resend` ships.
15
16#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
18
19mod inbound;
20
21pub use inbound::{MessageDetail, MessagePage, MessageQuery, MessageSummary};
22
23use async_trait::async_trait;
24use bytes::Bytes;
25use cratefield_core::{
26    Clock, HttpClient, HttpError, MailError, MailProvider, Mailer, Message, SendOutcome,
27    retry_after,
28};
29use http::header::{AUTHORIZATION, CONTENT_TYPE};
30use http::{Request, StatusCode};
31use std::fmt;
32use std::sync::Arc;
33use std::time::Duration;
34
35pub mod webhook;
36
37pub use webhook::{Envelope, OwlpostEvent, WebhookError, parse_verified};
38
39mod suppressions;
40
41pub use suppressions::Suppression;
42
43mod domains;
44
45pub use domains::{DnsRecord, Domain, DomainError, DomainStatus, RecordPurpose, RecordType};
46
47/// The hosted Owlpost API. A self-hosted or proxy deployment points the
48/// adapter elsewhere with [`Owlpost::with_base_url`] /
49/// [`OWLPOST_BASE_URL`](Owlpost::from_env); the wire path `/v1/emails` is
50/// appended to whatever base is configured.
51pub const DEFAULT_BASE_URL: &str = "https://api.owlpost.to";
52
53/// The largest batch [`Owlpost::batch`] accepts (the Resend-compatible cap).
54/// A larger batch is refused without a request.
55pub const MAX_BATCH: usize = 100;
56
57/// `Mailer` over `POST {base}/v1/emails`, holding the default sender and
58/// delegating the wire to `OwlpostClient`. `Debug` delegates to the client's,
59/// so it never prints the key.
60#[derive(Debug)]
61pub struct Owlpost {
62    client: OwlpostClient,
63    from: String,
64    reply_to: Option<String>,
65}
66
67impl Owlpost {
68    /// `api_key: None` — or a blank key, which is no key — makes the adapter
69    /// `NotConfigured` (no network). `from` is the default sender; a
70    /// [`Message`] with a non-empty `from` overrides it.
71    pub fn new(
72        http: Arc<dyn HttpClient>,
73        clock: Arc<dyn Clock>,
74        api_key: Option<String>,
75        from: impl Into<String>,
76        reply_to: Option<String>,
77    ) -> Self {
78        Self {
79            client: OwlpostClient::new(http, clock, api_key),
80            from: from.into(),
81            reply_to,
82        }
83    }
84
85    /// Points the adapter at a different base (self-hosted, proxy); a
86    /// trailing slash is harmless.
87    #[must_use]
88    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
89        self.client = self.client.with_base_url(base_url);
90        self
91    }
92
93    /// Reads `OWLPOST_API_KEY`, `OWLPOST_BASE_URL`, `MAIL_FROM` and
94    /// `MAIL_REPLY_TO`. On Workers read the secrets from the venture's `Env`
95    /// and use [`Owlpost::new`] instead.
96    pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
97        Self::new(
98            http,
99            clock,
100            std::env::var("OWLPOST_API_KEY").ok(),
101            std::env::var("MAIL_FROM").unwrap_or_else(|_| String::new()),
102            std::env::var("MAIL_REPLY_TO").ok(),
103        )
104        .with_base_url(base_url_from_env(std::env::var("OWLPOST_BASE_URL").ok()))
105    }
106
107    /// [`Mailer::send`] with the extra [`SendOptions`] the port cannot express.
108    /// Local refusals are checked before the key, so a programming error
109    /// surfaces even with no key configured.
110    ///
111    /// # Errors
112    ///
113    /// [`MailError::Invalid`] for a `topic` with [`Stream::Transactional`].
114    // The whole send travels by value, as `Mailer::send` does for the message.
115    #[allow(clippy::needless_pass_by_value)]
116    pub async fn send_with(
117        &self,
118        message: Message,
119        options: SendOptions,
120    ) -> Result<SendOutcome, MailError> {
121        match self
122            .client
123            .send_email(&self.from, self.reply_to.as_deref(), &message, &options)
124            .await
125        {
126            Ok(id) => Ok(SendOutcome::Sent { id }),
127            Err(OwlpostError::NotConfigured) => Ok(SendOutcome::NotConfigured),
128            Err(error) => Err(error.into()),
129        }
130    }
131
132    /// Sends up to [`MAX_BATCH`] emails in one `POST {base}/v1/emails/batch`,
133    /// returning the ids in order. The refusals are local and precede the key
134    /// check.
135    ///
136    /// # Errors
137    ///
138    /// [`OwlpostError`] for an empty or oversized batch, or an item with a
139    /// `topic` on [`Stream::Transactional`].
140    #[allow(clippy::needless_pass_by_value)]
141    pub async fn batch(
142        &self,
143        emails: Vec<(Message, SendOptions)>,
144        idempotency_key: Option<String>,
145    ) -> Result<Vec<String>, OwlpostError> {
146        self.client
147            .send_batch(
148                &self.from,
149                self.reply_to.as_deref(),
150                &emails,
151                idempotency_key.as_deref(),
152            )
153            .await
154    }
155
156    /// Fetches one sent email by provider id (`GET {base}/v1/emails/{id}`);
157    /// the id is validated locally, before the key check.
158    ///
159    /// # Errors
160    ///
161    /// [`OwlpostError`] for an empty or path-like id.
162    pub async fn get_email(&self, id: &str) -> Result<Email, OwlpostError> {
163        self.client.get_email(id).await
164    }
165}
166
167/// The reusable request engine: base URL, API key, clock, one `call` for every
168/// request and one status mapper. It carries no default sender, so each method
169/// takes the resolved `from`/`reply_to`. Crate-internal; reach it via
170/// [`Owlpost`].
171pub(crate) struct OwlpostClient {
172    http: Arc<dyn HttpClient>,
173    /// Reads the HTTP-date form of `Retry-After` (issue #278).
174    clock: Arc<dyn Clock>,
175    api_key: Option<String>,
176    base_url: String,
177}
178
179/// Names the base URL and whether a key is configured — never the key.
180impl fmt::Debug for OwlpostClient {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.debug_struct("OwlpostClient")
183            .field("base_url", &self.base_url)
184            .field("api_key_configured", &self.api_key.is_some())
185            .finish_non_exhaustive()
186    }
187}
188
189impl OwlpostClient {
190    /// `api_key: None` — or blank — makes every request `NotConfigured` with no
191    /// network. The base is [`DEFAULT_BASE_URL`].
192    fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>, api_key: Option<String>) -> Self {
193        Self {
194            http,
195            clock,
196            api_key: normalize_key(api_key),
197            base_url: DEFAULT_BASE_URL.to_owned(),
198        }
199    }
200
201    /// Points the client at a different base; a trailing slash is harmless.
202    fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
203        self.base_url = base_url.into();
204        self
205    }
206
207    /// A transport failure, with any key its text echoes redacted.
208    fn transport(&self, err: impl fmt::Display) -> OwlpostError {
209        OwlpostError::Mail(MailError::Transport(self.redact(err.to_string())))
210    }
211
212    /// One request: builds the URL and headers, sends it, and returns the body
213    /// text on success, or the mapped error. No key is `NotConfigured` and
214    /// nothing is sent.
215    async fn call(
216        &self,
217        method: http::Method,
218        path: &str,
219        body: Option<Vec<u8>>,
220        idempotency_key: Option<&str>,
221    ) -> Result<String, OwlpostError> {
222        self.call_mapped(
223            method,
224            path,
225            body,
226            idempotency_key,
227            |client, status, text, retry_after| -> OwlpostError {
228                client.map_status(status, text, retry_after).into()
229            },
230        )
231        .await
232    }
233
234    /// The same request, with the caller's own mapping for a non-success
235    /// response. [`Self::call`] keeps the [`MailError`] mapping for the mail
236    /// routes; the sending domains map 404, 409 and 422 to their own
237    /// variants, which [`MailError`] would fold into one. A no-key short
238    /// circuit and a transport failure both arrive as `E::from`.
239    async fn call_mapped<E>(
240        &self,
241        method: http::Method,
242        path: &str,
243        body: Option<Vec<u8>>,
244        idempotency_key: Option<&str>,
245        map: impl FnOnce(&OwlpostClient, StatusCode, &str, Option<Duration>) -> E,
246    ) -> Result<String, E>
247    where
248        E: From<OwlpostError> + fmt::Display,
249    {
250        let Some(api_key) = &self.api_key else {
251            tracing::info!(
252                provider = "owlpost",
253                outcome = "not_configured",
254                idempotency = idempotency_key.unwrap_or(""),
255                "mailer outcome"
256            );
257            return Err(E::from(OwlpostError::NotConfigured));
258        };
259        // What the log calls the answer: a POST sends and a PUT renames (both
260        // "sent"), a DELETE removes, a GET reads.
261        let outcome = match method {
262            http::Method::POST | http::Method::PUT | http::Method::PATCH => "sent",
263            http::Method::DELETE => "deleted",
264            _ => "read",
265        };
266        let mut builder = Request::builder()
267            .method(method)
268            .uri(format!("{}{path}", self.base_url.trim_end_matches('/')))
269            .header(AUTHORIZATION, format!("Bearer {api_key}"));
270        if body.is_some() {
271            builder = builder.header(CONTENT_TYPE, "application/json");
272        }
273        if let Some(key) = idempotency_key {
274            builder = builder.header("Idempotency-Key", key);
275        }
276        let request = builder
277            .body(Bytes::from(body.unwrap_or_default()))
278            .map_err(|err| E::from(self.transport(err)))?;
279
280        let response = self
281            .http
282            .send(request)
283            .await
284            .map_err(|err: HttpError| E::from(self.transport(err)))?;
285        let status = response.status();
286        let retry_after = retry_after(response.headers(), self.clock.as_ref());
287        let text = String::from_utf8_lossy(response.body()).to_string();
288        if status.is_success() {
289            tracing::info!(
290                provider = "owlpost",
291                code = status.as_u16(),
292                outcome,
293                idempotency = idempotency_key.unwrap_or(""),
294                "mailer outcome"
295            );
296            return Ok(text);
297        }
298        let error = map(self, status, &text, retry_after);
299        tracing::warn!(
300            provider = "owlpost",
301            code = status.as_u16(),
302            outcome = "failed",
303            idempotency = idempotency_key.unwrap_or(""),
304            error = %error,
305            "mailer outcome"
306        );
307        Err(error)
308    }
309
310    /// `POST {base}/v1/emails` for one message, returning the provider id.
311    async fn send_email(
312        &self,
313        from_default: &str,
314        reply_to_default: Option<&str>,
315        message: &Message,
316        options: &SendOptions,
317    ) -> Result<String, OwlpostError> {
318        refuse_topic_with_transactional(options)?;
319        let payload = outbound(from_default, reply_to_default, message, options);
320        let body = serde_json::to_vec(&payload).map_err(|err| self.transport(err))?;
321        let text = self
322            .call(
323                http::Method::POST,
324                "/v1/emails",
325                Some(body),
326                message.idempotency_key.as_deref(),
327            )
328            .await?;
329        let parsed: SendResponse =
330            serde_json::from_str(&text).map_err(|err| self.transport(err))?;
331        Ok(parsed.id)
332    }
333
334    /// `POST {base}/v1/emails/batch` for up to [`MAX_BATCH`] messages.
335    async fn send_batch(
336        &self,
337        from_default: &str,
338        reply_to_default: Option<&str>,
339        emails: &[(Message, SendOptions)],
340        idempotency_key: Option<&str>,
341    ) -> Result<Vec<String>, OwlpostError> {
342        if emails.is_empty() {
343            return Err(MailError::Invalid {
344                detail: "a batch must contain at least one email".to_owned(),
345            }
346            .into());
347        }
348        if emails.len() > MAX_BATCH {
349            return Err(MailError::Invalid {
350                detail: format!(
351                    "a batch of {} emails exceeds the {MAX_BATCH} maximum",
352                    emails.len()
353                ),
354            }
355            .into());
356        }
357        for (_, options) in emails {
358            refuse_topic_with_transactional(options)?;
359        }
360        let payloads: Vec<OutboundEmail<'_>> = emails
361            .iter()
362            .map(|(message, options)| outbound(from_default, reply_to_default, message, options))
363            .collect();
364        let body = serde_json::to_vec(&payloads).map_err(|err| self.transport(err))?;
365        let text = self
366            .call(
367                http::Method::POST,
368                "/v1/emails/batch",
369                Some(body),
370                idempotency_key,
371            )
372            .await?;
373        let parsed: BatchResponse =
374            serde_json::from_str(&text).map_err(|err| self.transport(err))?;
375        Ok(parsed.data.into_iter().map(|item| item.id).collect())
376    }
377
378    /// `GET {base}/v1/emails/{id}` for one sent email.
379    async fn get_email(&self, id: &str) -> Result<Email, OwlpostError> {
380        if !valid_email_id(id) {
381            return Err(MailError::Invalid {
382                detail: format!("invalid email id {id:?}"),
383            }
384            .into());
385        }
386        let text = self
387            .call(http::Method::GET, &format!("/v1/emails/{id}"), None, None)
388            .await?;
389        serde_json::from_str(&text).map_err(|err| self.transport(err))
390    }
391
392    /// Maps a non-success response to a [`MailError`], with the API key
393    /// stripped from any provider text first (see [`Self::redact`]).
394    fn map_status(
395        &self,
396        status: StatusCode,
397        body: &str,
398        retry_after: Option<Duration>,
399    ) -> MailError {
400        let detail = self.redact(problem_detail(body));
401        match status {
402            StatusCode::UNAUTHORIZED => MailError::Unauthorized,
403            // Owlpost has no problem `type` for an unverified domain, so the
404            // 403 is classified by its own wording, exactly as
405            // `adapter-resend` does for its `{"message"}` body.
406            StatusCode::FORBIDDEN => {
407                let lowered = detail.to_ascii_lowercase();
408                if lowered.contains("verify") || lowered.contains("domain") {
409                    MailError::DomainNotVerified {
410                        domain: extract_domain(&detail),
411                    }
412                } else {
413                    MailError::Unauthorized
414                }
415            }
416            StatusCode::TOO_MANY_REQUESTS => MailError::RateLimited { retry_after },
417            status if status.is_server_error() => {
418                MailError::Upstream(upstream_detail(detail, retry_after))
419            }
420            // Every other 4xx is the message or key being refused: 400 a
421            // malformed body, 404 a message that is not there, 422 content
422            // the provider will not accept. Permanent.
423            status if status.is_client_error() => MailError::Invalid { detail },
424            status => MailError::Upstream(format!("unexpected status {status}: {detail}")),
425        }
426    }
427
428    /// Whether a key is held. For `/__health`; never the key itself.
429    fn is_configured(&self) -> bool {
430        self.api_key.is_some()
431    }
432
433    /// Replaces the API key wherever the provider's own text echoed it, so no
434    /// error can hand the credential back to a logger.
435    fn redact(&self, text: String) -> String {
436        match &self.api_key {
437            Some(key) => text.replace(key.as_str(), "[redacted]"),
438            None => text,
439        }
440    }
441}
442
443/// What one Owlpost call outside the [`Mailer`] port can fail with: the
444/// degraded-mode `NotConfigured`, or a wrapped [`MailError`] (a provider
445/// failure or a local refusal). `#[non_exhaustive]`: match with a wildcard.
446#[derive(Debug, Clone, PartialEq, Eq)]
447#[non_exhaustive]
448pub enum OwlpostError {
449    /// No API key, so no call was made. [`Mailer::send`] reports
450    /// `SendOutcome::NotConfigured` instead.
451    NotConfigured,
452    /// A mapped provider failure, or a local refusal.
453    Mail(MailError),
454}
455
456impl fmt::Display for OwlpostError {
457    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458        match self {
459            Self::NotConfigured => f.write_str("owlpost mailer is not configured (no API key)"),
460            Self::Mail(error) => error.fmt(f),
461        }
462    }
463}
464
465impl std::error::Error for OwlpostError {
466    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
467        match self {
468            Self::NotConfigured => None,
469            Self::Mail(error) => Some(error),
470        }
471    }
472}
473
474impl From<MailError> for OwlpostError {
475    fn from(error: MailError) -> Self {
476        Self::Mail(error)
477    }
478}
479
480/// `NotConfigured` has no `MailError` counterpart (the port returns
481/// `SendOutcome::NotConfigured` first) and defensively maps to `Unauthorized`.
482impl From<OwlpostError> for MailError {
483    fn from(error: OwlpostError) -> Self {
484        match error {
485            OwlpostError::NotConfigured => MailError::Unauthorized,
486            OwlpostError::Mail(error) => error,
487        }
488    }
489}
490
491/// Extra send options [`Message`] does not carry. `#[non_exhaustive]`: build
492/// with [`SendOptions::default`] and set fields — not a struct literal.
493#[derive(Debug, Clone, Default)]
494#[non_exhaustive]
495pub struct SendOptions {
496    /// The stream; `None` uses the provider's default.
497    pub stream: Option<Stream>,
498    /// A broadcast topic. Refused with [`Stream::Transactional`].
499    pub topic: Option<String>,
500    /// Carbon-copy recipients.
501    pub cc: Vec<String>,
502    /// Blind carbon-copy recipients.
503    pub bcc: Vec<String>,
504    /// When to send, as an RFC 3339 timestamp.
505    pub scheduled_at: Option<String>,
506}
507
508/// The stream a message is sent on, serialised lowercase.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
510#[serde(rename_all = "lowercase")]
511#[non_exhaustive]
512pub enum Stream {
513    Transactional,
514    Broadcast,
515}
516
517/// One sent email, as `GET {base}/v1/emails/{id}` returns it.
518#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
519#[non_exhaustive]
520pub struct Email {
521    pub id: String,
522    pub to: Vec<String>,
523    pub from: String,
524    pub subject: String,
525    pub created_at: String,
526    #[serde(default)]
527    pub last_event: Option<String>,
528}
529
530/// A key that is empty or only whitespace is no key: `Bearer ` is not a
531/// credential, and `OWLPOST_API_KEY=` in a shell or a secret that failed to
532/// mount must read as absent (degraded mode), not as a request the provider
533/// answers 401.
534fn normalize_key(key: Option<String>) -> Option<String> {
535    key.filter(|key| !key.trim().is_empty())
536}
537
538/// `OWLPOST_BASE_URL` honoured only when set and non-blank: a blank variable
539/// is no configuration, not a base of `""`.
540fn base_url_from_env(value: Option<String>) -> String {
541    match value {
542        Some(base) if !base.trim().is_empty() => base.trim().to_owned(),
543        _ => DEFAULT_BASE_URL.to_owned(),
544    }
545}
546
547#[derive(serde::Serialize)]
548struct OutboundEmail<'a> {
549    from: &'a str,
550    to: &'a str,
551    #[serde(skip_serializing_if = "Option::is_none")]
552    reply_to: Option<&'a str>,
553    subject: &'a str,
554    html: &'a str,
555    text: &'a str,
556    /// Extra RFC 5322 headers, in the order the caller set them (see
557    /// [`MessageHeaders`]).
558    #[serde(skip_serializing_if = "MessageHeaders::is_empty")]
559    headers: MessageHeaders<'a>,
560    #[serde(skip_serializing_if = "Vec::is_empty")]
561    tags: Vec<OwlpostTag>,
562    /// From [`SendOptions`]; omitted when unset, so a plain `send` body is
563    /// byte-for-byte what it always was.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    stream: Option<Stream>,
566    #[serde(skip_serializing_if = "Option::is_none")]
567    topic: Option<&'a str>,
568    #[serde(skip_serializing_if = "slice_is_empty")]
569    cc: &'a [String],
570    #[serde(skip_serializing_if = "slice_is_empty")]
571    bcc: &'a [String],
572    #[serde(skip_serializing_if = "Option::is_none")]
573    scheduled_at: Option<&'a str>,
574}
575
576/// `skip_serializing_if` for a slice field: serde hands the predicate `&&[T]`.
577fn slice_is_empty<T>(slice: &&[T]) -> bool {
578    slice.is_empty()
579}
580
581/// Builds one request body, resolving the sender: a non-empty
582/// `message.from` wins, otherwise the adapter default; `message.reply_to`
583/// wins, otherwise the default. Shared by `send` and every batch item.
584fn outbound<'a>(
585    from_default: &'a str,
586    reply_to_default: Option<&'a str>,
587    message: &'a Message,
588    options: &'a SendOptions,
589) -> OutboundEmail<'a> {
590    OutboundEmail {
591        from: if message.from.is_empty() {
592            from_default
593        } else {
594            message.from.as_str()
595        },
596        to: &message.to,
597        reply_to: message.reply_to.as_deref().or(reply_to_default),
598        subject: &message.subject,
599        html: &message.html,
600        text: &message.text,
601        headers: MessageHeaders(&message.headers),
602        tags: owlpost_tags(&message.tags),
603        stream: options.stream,
604        topic: options.topic.as_deref(),
605        cc: &options.cc,
606        bcc: &options.bcc,
607        scheduled_at: options.scheduled_at.as_deref(),
608    }
609}
610
611/// A message id is spliced into a path, so it must hold only the characters a
612/// provider id does — never a `/` or `..`. An empty id is refused too.
613fn valid_email_id(id: &str) -> bool {
614    !id.is_empty()
615        && id
616            .bytes()
617            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
618}
619
620/// Refuses, before any network call, a `topic` set with an explicit
621/// [`Stream::Transactional`]: a topic is broadcast-only.
622fn refuse_topic_with_transactional(options: &SendOptions) -> Result<(), MailError> {
623    if options.topic.is_some() && options.stream == Some(Stream::Transactional) {
624        return Err(MailError::Invalid {
625            detail: "a topic is only valid for broadcast mail; a transactional message must not \
626                     carry one"
627                .to_owned(),
628        });
629    }
630    Ok(())
631}
632
633/// Serialises `Message::headers` as a JSON object in insertion order.
634/// `serde_json` emits a map's entries as `Serialize` yields them, so the
635/// caller's order reaches the wire — the RFC 8058 pair stays paired — where a
636/// `BTreeMap` would sort it.
637struct MessageHeaders<'a>(&'a [(String, String)]);
638
639impl MessageHeaders<'_> {
640    fn is_empty(&self) -> bool {
641        self.0.is_empty()
642    }
643}
644
645impl serde::Serialize for MessageHeaders<'_> {
646    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
647        serializer.collect_map(self.0.iter().map(|(name, value)| (name, value)))
648    }
649}
650
651/// Owlpost requires each tag to be a `{ name, value }` object whose fields
652/// contain only ASCII letters, digits, `_` or `-` — the Resend shape
653/// (issue #110), which Owlpost inherits. A harness tag is a single label,
654/// so it becomes the `name` (sanitised) with a constant `value`.
655#[derive(serde::Serialize)]
656struct OwlpostTag {
657    name: String,
658    value: &'static str,
659}
660
661fn owlpost_tags(tags: &[String]) -> Vec<OwlpostTag> {
662    tags.iter()
663        .map(|tag| OwlpostTag {
664            name: sanitize_tag(tag),
665            value: "1",
666        })
667        .collect()
668}
669
670/// Keeps only the allowed tag characters (ASCII letters, digits, `_`, `-`),
671/// mapping anything else to `_`, and never emits an empty name.
672fn sanitize_tag(tag: &str) -> String {
673    let cleaned: String = tag
674        .chars()
675        .map(|c| {
676            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
677                c
678            } else {
679                '_'
680            }
681        })
682        .collect();
683    if cleaned.is_empty() {
684        "tag".to_owned()
685    } else {
686        cleaned
687    }
688}
689
690#[derive(serde::Deserialize)]
691struct SendResponse {
692    #[serde(default)]
693    id: String,
694}
695
696/// The `{"data": [{"id": "..."}]}` body a batch send answers with.
697#[derive(serde::Deserialize)]
698struct BatchResponse {
699    #[serde(default)]
700    data: Vec<SendResponse>,
701}
702
703/// An RFC 9457 `application/problem+json` body. Every field optional so a
704/// proxy's partial body still parses.
705#[derive(serde::Deserialize)]
706struct ProblemDetails {
707    #[serde(default)]
708    title: String,
709    #[serde(default)]
710    detail: String,
711}
712
713/// One human-readable line from a problem body: `title: detail` when both
714/// are present, whichever is, or the raw body when neither parses.
715fn problem_detail(body: &str) -> String {
716    match serde_json::from_str::<ProblemDetails>(body) {
717        Ok(problem) => {
718            let title = problem.title.trim();
719            let detail = problem.detail.trim();
720            match (title.is_empty(), detail.is_empty()) {
721                (false, false) => format!("{title}: {detail}"),
722                (false, true) => title.to_owned(),
723                (true, false) => detail.to_owned(),
724                (true, true) => body.to_string(),
725            }
726        }
727        Err(_) => body.to_string(),
728    }
729}
730
731/// Pulls the sending domain out of a problem detail like "Domain
732/// send.example.test is not verified" — the first dot-separated token,
733/// otherwise the whole message. The Resend-compatible 403 shape.
734fn extract_domain(message: &str) -> String {
735    message
736        .split_whitespace()
737        .find(|token| token.contains('.') && !token.starts_with('"'))
738        .unwrap_or(message)
739        .trim_matches(['"', '.', ','])
740        .to_string()
741}
742
743/// A 5xx maps to `Upstream(String)`, which carries no structured retry hint,
744/// so a named `Retry-After` is appended: "later" is the whole point of a
745/// retryable answer.
746fn upstream_detail(detail: String, retry_after: Option<Duration>) -> String {
747    match retry_after {
748        Some(after) => format!("{detail} (retry after {}s)", after.as_secs()),
749        None => detail,
750    }
751}
752
753#[async_trait]
754impl Mailer for Owlpost {
755    /// Owlpost is the only provider behind this adapter, and it has no
756    /// probe: whether the key works is only learned by sending, so health
757    /// stays unknown rather than being guessed.
758    fn providers(&self) -> Vec<MailProvider> {
759        vec![MailProvider::new("owlpost", self.client.is_configured())]
760    }
761
762    /// Delegates to [`Owlpost::send_with`] with no extra options; the outcome
763    /// logging lives in the client, per issue #14.
764    async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
765        self.send_with(message, SendOptions::default()).await
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn blank_key_or_base_url_reads_as_unset() {
775        // A blank variable is no configuration, not a key of `""` or a base
776        // of `""`.
777        assert_eq!(normalize_key(None), None);
778        assert_eq!(normalize_key(Some(String::new())), None);
779        assert_eq!(normalize_key(Some("   ".to_owned())), None);
780        assert_eq!(
781            normalize_key(Some("op_live_x".to_owned())),
782            Some("op_live_x".to_owned())
783        );
784
785        assert_eq!(base_url_from_env(None), DEFAULT_BASE_URL);
786        assert_eq!(base_url_from_env(Some(String::new())), DEFAULT_BASE_URL);
787        assert_eq!(base_url_from_env(Some("   ".to_owned())), DEFAULT_BASE_URL);
788        assert_eq!(
789            base_url_from_env(Some("  http://owlpost.fake  ".to_owned())),
790            "http://owlpost.fake"
791        );
792    }
793
794    #[test]
795    fn tag_names_never_contain_a_forbidden_character() {
796        assert_eq!(sanitize_tag("waitlist:cratefield"), "waitlist_cratefield");
797        assert_eq!(sanitize_tag(""), "tag");
798        assert_eq!(sanitize_tag("ok-name_1"), "ok-name_1");
799    }
800
801    #[test]
802    fn problem_detail_prefers_title_and_detail() {
803        assert_eq!(
804            problem_detail(r#"{"title":"Bad Request","detail":"bad to"}"#),
805            "Bad Request: bad to"
806        );
807        assert_eq!(problem_detail(r#"{"title":"Bad Request"}"#), "Bad Request");
808        assert_eq!(problem_detail(r#"{"detail":"bad to"}"#), "bad to");
809        // Unparseable: the raw body is the fallback.
810        assert_eq!(problem_detail("boom"), "boom");
811    }
812}